Merge remote-tracking branch 'origin/main' into dg-onto-main

This commit is contained in:
Daniel Han 2026-06-12 06:34:20 +00:00
commit 643b829367
55 changed files with 1446 additions and 402 deletions

View file

@ -1876,7 +1876,7 @@ shell.Run cmd, 0, False
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.2" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.3" unsloth-zoo }
if ($baseInstallExit -eq 0) {
# Resolve pydantic WITH deps so pip pins pydantic-core
# to the matching version (no-torch-runtime.txt below
@ -1890,7 +1890,7 @@ shell.Run cmd, 0, False
}
}
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.2" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.3" unsloth-zoo }
}
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
@ -1937,7 +1937,7 @@ shell.Run cmd, 0, False
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.2" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.3" unsloth-zoo }
if ($baseInstallExit -eq 0) {
# Same pydantic-with-deps trick as the migrated branch.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython pydantic }
@ -1949,7 +1949,7 @@ shell.Run cmd, 0, False
}
}
} elseif ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.2" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.3" unsloth-zoo }
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
}
@ -1977,7 +1977,7 @@ shell.Run cmd, 0, False
Write-TauriLog "STEP" "Installing unsloth"
substep "installing unsloth (this may take a few minutes)..."
if ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.6.2" --torch-backend=auto }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.6.3" --torch-backend=auto }
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)

View file

@ -2405,7 +2405,7 @@ if [ "$_MIGRATED" = true ]; then
# to prevent transitive torch resolution.
run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.6.2" unsloth-zoo
"unsloth>=2026.6.3" unsloth-zoo
# Resolve pydantic WITH deps so pip pins pydantic-core to the
# matching version (no-torch-runtime.txt below is --no-deps).
# All transitive deps are torch-free.
@ -2418,7 +2418,7 @@ if [ "$_MIGRATED" = true ]; then
else
run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.6.2" unsloth-zoo
"unsloth>=2026.6.3" unsloth-zoo
fi
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
substep "overlaying local repo (editable)..."
@ -2622,7 +2622,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--upgrade-package unsloth --upgrade-package unsloth-zoo \
"unsloth>=2026.6.2" unsloth-zoo
"unsloth>=2026.6.3" unsloth-zoo
# Same pydantic-with-deps trick as the migrated branch.
run_install_cmd "install pydantic (with deps for compatible core)" \
uv pip install --python "$_VENV_PY" pydantic
@ -2640,7 +2640,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
fi
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \
--upgrade-package unsloth "unsloth>=2026.6.2" unsloth-zoo
--upgrade-package unsloth "unsloth>=2026.6.3" unsloth-zoo
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
substep "overlaying unsloth-zoo from git main..."
@ -2672,7 +2672,7 @@ else
tauri_log "STEP" "Installing Unsloth"
substep "installing unsloth (this may take a few minutes)..."
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.6.2" --torch-backend=auto
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.6.3" --torch-backend=auto
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
substep "overlaying unsloth-zoo from git main..."

View file

@ -70,7 +70,7 @@ triton = [
]
huggingfacenotorch = [
"unsloth_zoo>=2026.6.2",
"unsloth_zoo>=2026.6.3",
"wheel>=0.42.0",
"packaging",
"numpy",
@ -91,7 +91,7 @@ huggingfacenotorch = [
]
huggingface = [
"unsloth[huggingfacenotorch]",
"unsloth_zoo>=2026.6.2",
"unsloth_zoo>=2026.6.3",
"torchvision",
"unsloth[triton]",
]
@ -581,7 +581,7 @@ colab-ampere-torch220 = [
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
]
colab-new = [
"unsloth_zoo>=2026.6.2",
"unsloth_zoo>=2026.6.3",
"packaging",
"tyro",
"transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0",

View file

@ -25,6 +25,7 @@ from utils.hardware import (
get_visible_gpu_count,
)
from core.inference.audio_codecs import AudioCodecManager
from core.inference.runtime_context import runtime_context_length
from io import StringIO
import structlog
from loggers import get_logger
@ -405,6 +406,10 @@ class InferenceBackend:
# Reject CPU/disk offload for audio models too
raise_if_offloaded(self.models[model_name]["model"], device_map, "Inference")
self.models[model_name]["context_length"] = runtime_context_length(
self.models[model_name].get("model"),
max_seq_length,
)
self.active_model_name = model_name
self.loading_models.discard(model_name)
@ -485,6 +490,10 @@ class InferenceBackend:
self.models[model_name]["tokenizer"] = tokenizer
raise_if_offloaded(self.models[model_name]["model"], device_map, "Inference")
self.models[model_name]["context_length"] = runtime_context_length(
self.models[model_name].get("model"),
max_seq_length,
)
self._load_chat_template_info(model_name)

View file

@ -7,6 +7,7 @@ instead of torch/transformers for model loading and generation.
import threading
from typing import Optional, Generator
from core.inference.runtime_context import runtime_context_length
from loggers import get_logger
logger = get_logger(__name__)
@ -175,6 +176,7 @@ class MLXInferenceBackend:
"is_audio": False,
"audio_type": None,
"has_audio_input": False,
"context_length": runtime_context_length(self._model, max_seq_length),
}
# Capture chat_template_info so the worker IPC reply ships it back and
# the route layer classifies capabilities like the other paths.

View file

@ -727,6 +727,7 @@ class InferenceOrchestrator:
"is_audio": model_info.get("is_audio", False),
"audio_type": model_info.get("audio_type"),
"has_audio_input": model_info.get("has_audio_input", False),
"context_length": model_info.get("context_length"),
}
# Mirror chat_template_info so routes can classify caps
# without re-entering the subprocess.

View file

@ -0,0 +1,22 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Runtime context length helpers shared by inference backends."""
from __future__ import annotations
from typing import Any, Optional
def runtime_context_length(model: Any, fallback: Optional[int] = None) -> Optional[int]:
"""Return the effective context length Unsloth attached to a loaded model."""
for value in (getattr(model, "max_seq_length", None), fallback):
if isinstance(value, bool):
continue
try:
value_int = int(value)
except (TypeError, ValueError):
continue
if value_int > 0:
return value_int
return None

View file

@ -315,6 +315,18 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
"audio_type": getattr(mc, "audio_type", None),
"has_audio_input": getattr(mc, "has_audio_input", False),
}
try:
_bm = getattr(backend, "models", {}) or {}
_entry = (
_bm.get(mc.identifier)
or _bm.get(getattr(backend, "active_model_name", None))
or {}
)
_context_length = _entry.get("context_length")
if _context_length is not None:
model_info["context_length"] = int(_context_length)
except Exception as _ctx_exc:
logger.warning("context_length forward failed: %s", _ctx_exc)
# Forward chat_template_info so the parent can classify capabilities.
try:
_bm = getattr(backend, "models", {}) or {}
@ -881,6 +893,7 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf
name: {
"is_vision": info.get("is_vision", False),
"is_lora": info.get("is_lora", False),
"context_length": info.get("context_length"),
}
for name, info in backend.models.items()
},

View file

@ -174,7 +174,7 @@ class LoadResponse(BaseModel):
description = "Whether the model defaults require trust_remote_code to be enabled for loading.",
)
context_length: Optional[int] = Field(
None, description = "Model's native context length (from GGUF metadata)"
None, description = "Runtime context length in tokens for the loaded model"
)
max_context_length: Optional[int] = Field(
None, description = "Maximum context length currently available on this hardware"

View file

@ -29,6 +29,16 @@ from utils.models import extract_model_size_b as _extract_model_size_b
from utils.api_errors import openai_error_body, anthropic_error_body
def _positive_int_or_none(value: Any) -> Optional[int]:
if isinstance(value, bool):
return None
try:
value_int = int(value)
except (TypeError, ValueError):
return None
return value_int if value_int > 0 else None
def _install_httpcore_asyncgen_silencer() -> None:
"""Silence benign httpx/httpcore asyncgen GC noise on Python 3.13.
@ -1391,6 +1401,7 @@ async def load_model(
reasoning_always_on = _sf_flags["reasoning_always_on"],
supports_preserve_thinking = _sf_flags["supports_preserve_thinking"],
supports_tools = _sf_flags["supports_tools"],
context_length = _positive_int_or_none(_model_info.get("context_length")),
chat_template = _chat_template,
)
@ -1733,6 +1744,7 @@ async def load_model(
reasoning_always_on = _sf_flags["reasoning_always_on"],
supports_preserve_thinking = _sf_flags["supports_preserve_thinking"],
supports_tools = _sf_flags["supports_tools"],
context_length = _positive_int_or_none(_model_info.get("context_length")),
chat_template = _chat_template,
)
@ -2122,6 +2134,7 @@ async def get_status(current_subject: str = Depends(get_current_subject)):
reasoning_always_on = _sf_flags["reasoning_always_on"],
supports_preserve_thinking = _sf_flags["supports_preserve_thinking"],
supports_tools = _sf_flags["supports_tools"],
context_length = _positive_int_or_none(model_info.get("context_length")),
chat_template = chat_template,
llama_cpp_supports_mtp = _supports_mtp,
llama_cpp_prebuilt_stale = _stale,
@ -4628,28 +4641,38 @@ def _openai_model_objects() -> list[dict]:
"created": _created,
"owned_by": "local",
}
# Extension fields: the real per-request window (post /props readback)
# so clients can budget/compact against the enforced limit.
if llama_backend.context_length:
entry["context_length"] = llama_backend.context_length
if llama_backend.max_context_length:
entry["max_context_length"] = llama_backend.max_context_length
_ctx = _positive_int_or_none(getattr(llama_backend, "context_length", None))
if _ctx is not None:
entry["context_length"] = _ctx
_max_ctx = _positive_int_or_none(getattr(llama_backend, "max_context_length", None))
if _max_ctx is not None:
entry["max_context_length"] = _max_ctx
_native_ctx = _positive_int_or_none(getattr(llama_backend, "native_context_length", None))
if _native_ctx is not None:
entry["native_context_length"] = _native_ctx
models.append(entry)
# Check Unsloth backend
backend = get_inference_backend()
if backend.active_model_name:
model_info = backend.models.get(backend.active_model_name, {})
entry = {
"id": backend.active_model_name,
"object": "model",
"created": _created,
"owned_by": "local",
}
_sf_ctx = getattr(backend, "context_length", None) or getattr(
backend, "max_seq_length", None
)
if _sf_ctx:
entry["context_length"] = _sf_ctx
_ctx = _positive_int_or_none(model_info.get("context_length"))
if _ctx is None:
for _candidate in (
getattr(backend, "context_length", None),
getattr(backend, "max_seq_length", None),
):
_ctx = _positive_int_or_none(_candidate)
if _ctx is not None:
break
if _ctx is not None:
entry["context_length"] = _ctx
models.append(entry)
return models

View file

@ -31,6 +31,7 @@ class LlamaUpdateJob(BaseModel):
from_tag: Optional[str] = None
to_tag: Optional[str] = None
error: Optional[str] = None
progress: Optional[float] = Field(None, description = "0..1 while running, 1 on success.")
started_at: Optional[str] = None
finished_at: Optional[str] = None
@ -40,7 +41,9 @@ class LlamaUpdateStatusResponse(BaseModel):
False,
description = "True when the install came from an Unsloth prebuilt (has a marker).",
)
update_available: bool = Field(False, description = "True when installed_tag != latest_tag.")
update_available: bool = Field(
False, description = "True when the latest release is genuinely newer than the install."
)
stale: bool = Field(
False, description = "Update available AND install older than the staleness threshold."
)

View file

@ -232,6 +232,9 @@ def _verify_global_reachability(display_host: str, port: int) -> None:
public internet. Synchronous so output lands between the banner URLs and the
stop hint. Bounded at ~15s; failures swallowed (verifier failing != Studio
failing). Only meaningful for a wildcard bind."""
global _public_reachable
# Reset to "unknown" each run; set True/False only when the probe decides.
_public_reachable = None
import ipaddress
import json
import time
@ -324,12 +327,14 @@ def _verify_global_reachability(display_host: str, port: int) -> None:
print("", flush = True)
if ok_nodes:
_public_reachable = True
print(
f"{ok_c} Reachability check: {url}/ is reachable from the "
f"public internet ({ok_nodes}/{total} probe nodes connected).{reset}",
flush = True,
)
elif err_nodes:
_public_reachable = False
print(
f"{err_c} Reachability check: {url}/ is NOT reachable from "
f"the public internet ({err_nodes}/{total} probe nodes failed).{reset}",
@ -422,7 +427,9 @@ def _print_cloudflare_line() -> None:
"""Print the Cloudflare quick-tunnel URL for 0.0.0.0 binds, if one is up.
Reads the module-level URL set by ``run_server``. Prints nothing when the
tunnel is disabled or failed -- failures are silently ignored.
tunnel is disabled or failed -- failures are silently ignored. When the public
reachability probe just failed (``_public_reachable is False``) but the tunnel
is up, reword to point the user at the Cloudflare link as the way in.
"""
if not _cloudflare_url:
return
@ -430,7 +437,10 @@ def _print_cloudflare_line() -> None:
accent = "\033[38;5;150;1m"
reset = "\033[0m"
line = f" Secure link access via Cloudflare: {_cloudflare_url}"
if _public_reachable is False:
line = f" Use the secure link access via Cloudflare instead: {_cloudflare_url}"
else:
line = f" Secure link access via Cloudflare: {_cloudflare_url}"
print(f"{accent}{line}{reset}" if stdout_supports_color() else line)
@ -622,6 +632,12 @@ _shutdown_event = None
# None when there is no tunnel (loopback, disabled, or a silently-ignored failure).
_cloudflare_url = None
# Public reachability from the last _verify_global_reachability run, read by the
# Cloudflare banner line. True when the public ip:port probe confirmed reachable,
# False when it confirmed NOT reachable, None when the probe did not run or could
# not decide (timeout, blocked, private address).
_public_reachable = None
_DEFAULT_FRONTEND_PATH = Path(__file__).resolve().parent.parent / "frontend" / "dist"

View file

@ -13,6 +13,7 @@ import importlib.util
import io
import sys
import tarfile
import types
from pathlib import Path
import pytest
@ -423,3 +424,56 @@ def test_run_server_gates_tunnel_on_wildcard():
source = _RUN_PY.read_text()
assert "_cloudflare_enabled" in source
assert 'host == "0.0.0.0"' in source
def _run_print_cloudflare_line(monkeypatch, *, cloudflare_url, public_reachable):
"""Exec the real _print_cloudflare_line source in isolation (run.py has heavy
deps), with the two module globals injected and startup_banner stubbed."""
src = _RUN_PY.read_text()
tree = ast.parse(src)
func_src = next(
ast.get_source_segment(src, n)
for n in ast.walk(tree)
if isinstance(n, ast.FunctionDef) and n.name == "_print_cloudflare_line"
)
stub = types.ModuleType("startup_banner")
stub.stdout_supports_color = lambda: False
monkeypatch.setitem(sys.modules, "startup_banner", stub)
captured: list[str] = []
ns = {
"_cloudflare_url": cloudflare_url,
"_public_reachable": public_reachable,
"print": lambda *a, **k: captured.append(" ".join(str(x) for x in a)),
}
exec(compile(func_src, "<print_cloudflare_line>", "exec"), ns)
ns["_print_cloudflare_line"]()
return "\n".join(captured)
def test_cloudflare_line_reworded_when_public_unreachable(monkeypatch):
out = _run_print_cloudflare_line(
monkeypatch, cloudflare_url = "https://x.trycloudflare.com", public_reachable = False
)
assert "Use the secure link access via Cloudflare instead: https://x.trycloudflare.com" in out
def test_cloudflare_line_default_wording_when_reachable(monkeypatch):
out = _run_print_cloudflare_line(
monkeypatch, cloudflare_url = "https://x.trycloudflare.com", public_reachable = True
)
assert "Secure link access via Cloudflare: https://x.trycloudflare.com" in out
assert "Use the secure link" not in out
def test_cloudflare_line_default_wording_when_unknown(monkeypatch):
# Probe did not run / could not decide -> keep the existing wording.
out = _run_print_cloudflare_line(
monkeypatch, cloudflare_url = "https://x.trycloudflare.com", public_reachable = None
)
assert "Secure link access via Cloudflare: https://x.trycloudflare.com" in out
assert "Use the secure link" not in out
def test_cloudflare_line_prints_nothing_without_tunnel(monkeypatch):
out = _run_print_cloudflare_line(monkeypatch, cloudflare_url = None, public_reachable = False)
assert out == ""

View file

@ -32,14 +32,19 @@ if resolve_lemonade_rocm_choice is None or _LEMONADE_GFX_FAMILIES is None:
@pytest.fixture(autouse = True)
def _clear_lemonade_release_cache():
"""Prevent cross-test pollution of the lemonade release lru_cache when
future tests vary the fetch_json mock return value."""
"""Prevent cross-test pollution of the lemonade release lru_cache and
selection-log dedup set when tests vary the fetch_json mock return value."""
_cache = getattr(_mod, "_fetch_lemonade_release_cached", None)
_logged: set | None = getattr(_mod, "_lemonade_selection_logged", None)
if _cache is not None and hasattr(_cache, "cache_clear"):
_cache.cache_clear()
if _logged is not None:
_logged.clear()
yield
if _cache is not None and hasattr(_cache, "cache_clear"):
_cache.cache_clear()
if _logged is not None:
_logged.clear()
_STUB_TAG = "b1262"

View file

@ -21,12 +21,25 @@ _BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
class _NoopLogger:
"""structlog-style logger: every method swallows positional + kwargs.
A stdlib logging.Logger rejects structlog's keyword fields (e.g.
``logger.warning(msg, error=...)``), which leaked into the update module's
error path and failed only when this file's stub loaded first.
"""
def __getattr__(self, _name):
return lambda *a, **k: None
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
_loggers_stub.get_logger = lambda *a, **k: _NoopLogger()
sys.modules.setdefault("loggers", _loggers_stub)
_structlog_stub = _types.ModuleType("structlog")
_structlog_stub.get_logger = lambda *a, **k: __import__("logging").getLogger("stub")
_structlog_stub.get_logger = lambda *a, **k: _NoopLogger()
sys.modules.setdefault("structlog", _structlog_stub)
import pytest
@ -51,6 +64,11 @@ def _write_marker(install_dir: Path, **overrides) -> Path:
.replace("+00:00", "Z"),
}
payload.update(overrides)
# The installer always writes `tag` and `release_tag` from the same release
# (a normalized base vs the full release tag), so keep the pair consistent
# when a test overrides only `tag`.
if "tag" in overrides and "release_tag" not in overrides:
payload["release_tag"] = overrides["tag"]
install_dir.mkdir(parents = True, exist_ok = True)
(install_dir / "UNSLOTH_PREBUILT_INFO.json").write_text(json.dumps(payload))
return install_dir / "UNSLOTH_PREBUILT_INFO.json"
@ -303,3 +321,115 @@ def test_format_stale_warning_singular_day():
msg = fr.format_stale_warning({"installed_tag": "b9190", "latest_tag": "b9300", "age_days": 1})
assert "1 day" in msg
assert "1 days" not in msg
# parse_base_build / is_behind.
def test_parse_base_build():
assert fr.parse_base_build("b9596") == 9596
assert fr.parse_base_build(" b9596 ") == 9596
assert fr.parse_base_build("b9596-mix-e6f2453") == 9596 # mix suffix doesn't defeat it
assert fr.parse_base_build("9596") is None
assert fr.parse_base_build("master-abc") is None
assert fr.parse_base_build("") is None
assert fr.parse_base_build(None) is None
@pytest.mark.parametrize(
"installed, latest, expected",
[
(
"b9596-mix-e6f2453",
"b9596-mix-e6f2453",
False,
), # already on the mix latest -> not behind
("b9596", "b9594", False), # latest is an older build -> downgrade guard
("b9596", "b9594-mix-xxx", False), # older mix latest -> still guarded
("b9500", "b9596-mix-e6f2453", True), # newer base -> behind
("b9596-mix-aaa", "b9596-mix-bbb", True), # new mix at same base -> behind
("b9596", "b9596-mix-bbb", True), # clean -> mix at same base -> behind
("b9596-mix-aaa", "b9596", False), # bare base never supersedes a mix install
("b9596", "b9596", False), # identical -> not behind
(" b9596 ", "b9596", False), # whitespace-only diff -> not behind
("master-abc", "master-def", True), # non-bNNNN both -> plain inequality
("master-abc", "master-abc", False),
(None, "b9596", False),
("b9596", None, False),
],
)
def test_is_behind(installed, latest, expected):
assert fr.is_behind(installed, latest) is expected
def test_check_prebuilt_freshness_not_behind_on_mix_latest(monkeypatch, tmp_path):
# Installed the mix latest: marker base tag b9596, full release_tag with sha,
# GitHub latest is that same full tag. Must not report behind (sticky bug).
install_dir = tmp_path / "llama.cpp"
_write_marker(install_dir, tag = "b9596", release_tag = "b9596-mix-e6f2453")
bin_path = _fake_binary(install_dir, layout = "root")
monkeypatch.setattr(
fr, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9596-mix-e6f2453"
)
info = fr.check_prebuilt_freshness(str(bin_path))
assert info["behind"] is False
assert info["stale"] is False
def test_check_prebuilt_freshness_downgrade_guard(monkeypatch, tmp_path):
# A lagging latest (older build than installed) must never read as behind/stale.
install_dir = tmp_path / "llama.cpp"
_write_marker(
install_dir,
tag = "b9585",
installed_at_utc = (datetime.now(tz = timezone.utc) - timedelta(days = 30))
.isoformat()
.replace("+00:00", "Z"),
)
bin_path = _fake_binary(install_dir, layout = "root")
monkeypatch.setattr(fr, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
info = fr.check_prebuilt_freshness(str(bin_path))
assert info["behind"] is False
assert info["stale"] is False
def test_fetch_latest_release_tag_uses_publish_time(monkeypatch):
# Resolves newest by published_at (like the installer), skips drafts/prereleases,
# and does NOT just take GitHub's first/`/releases/latest` item.
import urllib.request
class _Resp:
def __init__(self, payload):
self._p = json.dumps(payload).encode()
def read(self):
return self._p
def __enter__(self):
return self
def __exit__(self, *a):
return False
payload = [
{
"tag_name": "b9518",
"draft": False,
"prerelease": False,
"published_at": "2026-06-04T21:11:19Z",
},
{
"tag_name": "b9596-mix-e6f2453",
"draft": False,
"prerelease": False,
"published_at": "2026-06-11T22:50:41Z",
},
{
"tag_name": "b9999-draft",
"draft": True,
"prerelease": False,
"published_at": "2026-06-12T00:00:00Z",
},
]
monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout = 5.0: _Resp(payload))
assert fr._fetch_latest_release_tag("unslothai/llama.cpp") == "b9596-mix-e6f2453"

View file

@ -28,23 +28,75 @@ import utils.llama_cpp_update as upd # noqa: E402
MARKER = "UNSLOTH_PREBUILT_INFO.json"
class _FakeInstallerPopen:
"""Stands in for the streamed installer process in _run_update."""
def __init__(
self,
cmd,
*,
returncode = 0,
lines = None,
on_start = None,
captured_kwargs = None,
**kwargs,
):
if captured_kwargs is not None:
captured_kwargs.update(kwargs)
if on_start is not None:
on_start(list(cmd))
self.returncode = returncode
self.stdout = iter(lines or [])
def wait(self):
return self.returncode
def kill(self):
pass
def _patch_installer_popen(
monkeypatch,
*,
returncode = 0,
lines = None,
on_start = None,
captured_kwargs = None,
):
monkeypatch.setattr(
upd.subprocess,
"Popen",
lambda cmd, **kw: _FakeInstallerPopen(
cmd,
returncode = returncode,
lines = lines,
on_start = on_start,
captured_kwargs = captured_kwargs,
**kw,
),
)
def _write_install(
dir_: Path,
tag: str,
repo: str = "unslothai/llama.cpp",
asset: str | None = None,
release_tag: str | None = None,
) -> str:
"""Create a fake prebuilt install tree and return the llama-server path.
``asset`` is the bundle filename recorded in the marker; omit it to model an
older marker that predates asset-based ROCm forwarding (backward compat)."""
older marker that predates asset-based ROCm forwarding (backward compat).
``release_tag`` is the full release tag (e.g. a ``b9596-mix-<sha>`` mix
build); defaults to ``tag`` for a plain prebuilt."""
bin_dir = dir_ / "build" / "bin"
bin_dir.mkdir(parents = True, exist_ok = True)
binary = bin_dir / "llama-server"
binary.write_text("#!/bin/sh\necho stub\n")
marker = {
"tag": tag,
"release_tag": tag,
"release_tag": release_tag or tag,
"published_repo": repo,
"installed_at_utc": "2020-01-01T00:00:00Z",
"bundle_profile": "cuda13-newer",
@ -57,10 +109,13 @@ def _write_install(
@pytest.fixture(autouse = True)
def _clean_state(monkeypatch):
def _clean_state(monkeypatch, tmp_path):
freshness.reset_caches()
upd._reset_job_for_tests()
upd._resolve_memo.clear()
# Isolate the freshness disk cache so the suite never writes the real
# ~/.unsloth cache (the default when storage_roots can't be imported).
monkeypatch.setattr(freshness, "_cache_dir", lambda: tmp_path / ".freshness_cache")
# Deterministic markerless paths: no host-pinned binary, no custom dir.
monkeypatch.delenv("LLAMA_SERVER_PATH", raising = False)
monkeypatch.delenv("UNSLOTH_LLAMA_CPP_PATH", raising = False)
@ -260,14 +315,15 @@ def test_start_update_source_build_installs_prebuilt(monkeypatch, tmp_path):
def _fake_run(cmd, **kwargs):
cmd = list(cmd)
# Status polls probe `llama-server --version`; keep the installer argv.
if "--version" in cmd:
return _Proc()
captured["cmd"] = cmd
_write_install(install_dir, "b9585") # installer writes the marker
assert "--version" in cmd # only status polls still use run()
return _Proc()
def _on_start(cmd):
captured["cmd"] = cmd
_write_install(install_dir, "b9585") # installer writes the marker
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
_patch_installer_popen(monkeypatch, on_start = _on_start)
res = upd.start_update()
assert res["started"] is True, res
@ -298,21 +354,27 @@ def test_start_update_happy_path(monkeypatch, tmp_path):
stdout = "installed"
stderr = ""
def _fake_run(cmd, **kwargs):
cmd = list(cmd)
# Status polls probe `llama-server --version`; keep the installer argv.
if "--version" in cmd:
return _Proc()
def _on_start(cmd):
captured["cmd"] = cmd
# Simulate the installer writing a new marker with the latest tag.
_write_install(install_dir, "b9518")
return _Proc()
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
popen_kwargs: dict = {}
_patch_installer_popen(
monkeypatch,
lines = [
"[llama-prebuilt] resolving release\n",
"Downloading llama.zip: 35.0% (12.0 MiB/35.0 MiB) at 9.0 MiB/s\n",
"Downloading llama.zip: 80.0% (28.0 MiB/35.0 MiB) at 9.0 MiB/s\n",
],
on_start = _on_start,
captured_kwargs = popen_kwargs,
)
res = upd.start_update()
assert res["started"] is True
assert res["job"]["from_tag"] == "b9493"
assert res["job"]["progress"] == 0.0
# Wait for the background worker.
deadline = time.time() + 10
@ -328,6 +390,10 @@ def test_start_update_happy_path(monkeypatch, tmp_path):
assert str(install_dir) in captured["cmd"]
assert "--llama-tag" in captured["cmd"] and "latest" in captured["cmd"]
assert "unslothai/llama.cpp" in captured["cmd"]
# Progress lines were parsed and success pins progress at 1.0.
assert job["progress"] == 1.0
# The worker asks the installer for fine-grained progress milestones.
assert popen_kwargs["env"]["UNSLOTH_PROGRESS_PERCENT_STEP"] == "5"
def test_start_update_installer_failure_reports_error(monkeypatch, tmp_path):
@ -335,13 +401,9 @@ def test_start_update_installer_failure_reports_error(monkeypatch, tmp_path):
binary = _write_install(install_dir, "b9493")
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
class _Proc:
returncode = 2
stdout = ""
stderr = "boom: network error"
monkeypatch.setattr(upd.subprocess, "run", lambda cmd, **kw: _Proc())
_patch_installer_popen(monkeypatch, returncode = 2, lines = ["boom: network error\n"])
res = upd.start_update()
assert res["started"] is True
@ -410,14 +472,15 @@ def _capture_install_cmd(
def _fake_run(cmd, **kwargs):
cmd = list(cmd)
# Status polls probe `llama-server --version`; keep the installer argv.
if "--version" in cmd:
return _Proc()
captured["cmd"] = cmd
_write_install(install_dir, latest, repo = repo, asset = asset)
assert "--version" in cmd # only status polls still use run()
return _Proc()
def _on_start(cmd):
captured["cmd"] = cmd
_write_install(install_dir, latest, repo = repo, asset = asset)
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
_patch_installer_popen(monkeypatch, on_start = _on_start)
res = upd.start_update()
assert res["started"] is True, res
@ -535,18 +598,12 @@ def test_update_sets_maintenance_flag_and_unloads(monkeypatch, tmp_path):
seen = {}
class _Proc:
returncode = 0
stdout = "ok"
stderr = ""
def _fake_run(cmd, **kwargs):
def _on_start(cmd):
# The maintenance flag must be set while the installer runs.
seen["flag_during_install"] = backend._llama_update_in_progress
_write_install(install_dir, "b9518")
return _Proc()
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
_patch_installer_popen(monkeypatch, on_start = _on_start)
res = upd.start_update()
assert res["started"] is True
@ -567,16 +624,12 @@ def test_update_clears_maintenance_flag_on_installer_failure(monkeypatch, tmp_pa
binary = _write_install(install_dir, "b9493")
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
backend = _FakeBackend()
_inject_backend(monkeypatch, backend)
class _Proc:
returncode = 1
stdout = ""
stderr = "boom"
monkeypatch.setattr(upd.subprocess, "run", lambda cmd, **kw: _Proc())
_patch_installer_popen(monkeypatch, returncode = 1, lines = ["boom\n"])
res = upd.start_update()
assert res["started"] is True
@ -606,16 +659,7 @@ def test_update_fails_open_when_backend_unavailable(monkeypatch, tmp_path):
monkeypatch.setitem(sys.modules, "routes", routes_pkg)
monkeypatch.setitem(sys.modules, "routes.inference", inference_mod)
class _Proc:
returncode = 0
stdout = "ok"
stderr = ""
def _fake_run(cmd, **kwargs):
_write_install(install_dir, "b9518")
return _Proc()
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
_patch_installer_popen(monkeypatch, on_start = lambda cmd: _write_install(install_dir, "b9518"))
res = upd.start_update()
assert res["started"] is True
@ -759,3 +803,41 @@ def test_start_update_source_build_refuses_when_newer(monkeypatch, tmp_path):
res = upd.start_update()
assert res["started"] is False
assert res["reason"] == "up_to_date"
# --- mix-tag detection + apply guard (the reported banner bug) ---
def test_status_not_offered_on_mix_latest(monkeypatch, tmp_path):
# Installed the mix latest; GitHub latest is that same full tag -> no banner.
binary = _write_install(tmp_path / "llama.cpp", "b9596", release_tag = "b9596-mix-e6f2453")
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
monkeypatch.setattr(
freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9596-mix-e6f2453"
)
st = upd.get_update_status()
assert st["update_available"] is False
assert st["installed_tag"] == "b9596"
assert st["latest_tag"] == "b9596-mix-e6f2453"
def test_status_not_offered_when_latest_lags(monkeypatch, tmp_path):
# A lagging latest (older build than installed) must never be offered.
binary = _write_install(tmp_path / "llama.cpp", "b9585")
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
st = upd.get_update_status()
assert st["update_available"] is False
def test_start_update_marked_refuses_when_not_behind(monkeypatch, tmp_path):
# A direct POST / stale banner must not reinstall when already on the latest.
binary = _write_install(tmp_path / "llama.cpp", "b9596", release_tag = "b9596-mix-e6f2453")
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
monkeypatch.setattr(
freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9596-mix-e6f2453"
)
res = upd.start_update()
assert res["started"] is False
assert res["reason"] == "up_to_date"

View file

@ -271,6 +271,7 @@ class TestPydanticModels:
def test_load_response_has_field(self):
"""Field exists in LoadResponse.model_fields."""
assert "native_context_length" in LoadResponse.model_fields
assert "context_length" in LoadResponse.model_fields
def test_load_response_defaults_none(self):
"""Omitting native_context_length defaults to None."""
@ -319,6 +320,7 @@ class TestPydanticModels:
def test_status_response_has_field(self):
"""Field exists in InferenceStatusResponse.model_fields."""
assert "native_context_length" in InferenceStatusResponse.model_fields
assert "context_length" in InferenceStatusResponse.model_fields
def test_status_response_has_chat_template_field(self):
"""Status includes chat_template so the UI can rehydrate after refresh."""
@ -347,6 +349,18 @@ class TestPydanticModels:
roundtripped = LoadResponse.model_validate_json(resp.model_dump_json())
assert roundtripped.native_context_length == 131072
def test_context_length_roundtrip(self):
"""Runtime context_length serializes for non-GGUF/hub models."""
resp = LoadResponse(
status = "loaded",
model = "test",
display_name = "Test",
inference = {},
context_length = 8192,
)
roundtripped = LoadResponse.model_validate_json(resp.model_dump_json())
assert roundtripped.context_length == 8192
# =====================================================================
# D. TestRouteCompleteness -- source-level verification
@ -408,6 +422,16 @@ class TestRouteCompleteness:
"native_context_length" not in block
), f"Non-GGUF LoadResponse should not set native_context_length:\n{block[:200]}"
def test_non_gguf_load_responses_set_runtime_context_length(self):
"""Non-GGUF LoadResponse blocks report runtime context_length."""
blocks = self._find_construction_blocks("LoadResponse")
non_gguf = [b for b in blocks if "is_gguf = True" not in b and "is_gguf=True" not in b]
assert non_gguf, "Expected at least one non-GGUF LoadResponse block"
for block in non_gguf:
assert (
"context_length" in block
), f"Non-GGUF LoadResponse should set context_length:\n{block[:200]}"
def test_status_path(self):
"""InferenceStatusResponse construction with llama_backend has the field."""
blocks = self._find_construction_blocks("InferenceStatusResponse")
@ -420,6 +444,21 @@ class TestRouteCompleteness:
found
), "No InferenceStatusResponse block with llama_backend has native_context_length"
def test_non_gguf_status_path_reports_runtime_context_length(self):
"""Non-GGUF InferenceStatusResponse reports context_length from model_info."""
blocks = self._find_construction_blocks("InferenceStatusResponse")
found = False
for block in blocks:
if "is_gguf = False" in block and "context_length" in block:
found = True
break
assert found, "No non-GGUF InferenceStatusResponse block with context_length"
def test_openai_models_listing_reports_context_length(self):
"""/v1/models includes context_length when the backend knows it."""
assert 'entry["context_length"]' in self._source
assert 'model_info.get("context_length")' in self._source
# =====================================================================
# E. TestEdgeCases

View file

@ -13,6 +13,7 @@ from __future__ import annotations
import json
import os
import re
import time
from datetime import datetime, timezone
from pathlib import Path
@ -104,11 +105,18 @@ def _save_disk_cache(repo: str, latest_tag: Optional[str]) -> None:
def _fetch_latest_release_tag(repo: str, timeout: float = 5.0) -> Optional[str]:
"""GitHub API call. None on any failure (offline, rate-limited, etc)."""
"""Newest published release tag for `repo`, by publish time.
Resolves "latest" the way install_llama_prebuilt.py does (newest
non-draft/non-prerelease by ``published_at``), NOT via GitHub's
``/releases/latest`` pointer. That pointer sorts by commit date and can lag
behind the build the installer actually installs, so detection and apply
disagreed -- the cause of the downgrade/sticky banner. None on any failure
(offline, rate-limited, etc)."""
import urllib.error
import urllib.request
url = f"https://api.github.com/repos/{repo}/releases/latest"
url = f"https://api.github.com/repos/{repo}/releases?per_page=30"
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "unsloth-studio-freshness-check",
@ -128,8 +136,21 @@ def _fetch_latest_release_tag(repo: str, timeout: float = 5.0) -> Optional[str]:
) as exc:
logger.debug("freshness fetch failed", repo = repo, error = str(exc))
return None
tag = data.get("tag_name")
return tag if isinstance(tag, str) and tag else None
if not isinstance(data, list):
return None
published = [
r
for r in data
if isinstance(r, dict)
and not r.get("draft")
and not r.get("prerelease")
and isinstance(r.get("tag_name"), str)
and r.get("tag_name")
]
if not published:
return None
newest = max(published, key = lambda r: r.get("published_at") or "")
return newest["tag_name"]
def latest_published_release(repo: str, *, force_refresh: bool = False) -> Optional[str]:
@ -172,19 +193,58 @@ def _parse_installed_at(value: object) -> Optional[datetime]:
return dt
def parse_base_build(tag: object) -> Optional[int]:
"""Numeric base build from a release tag. Handles both a plain ``bNNNN`` and
a mix-build tag like ``b9596-mix-<sha>`` (anchored at the start, so the mix
suffix doesn't defeat it). None for anything not starting with ``bNNNN``."""
if not isinstance(tag, str):
return None
m = re.match(r"b(\d+)", tag.strip())
return int(m.group(1)) if m else None
def is_behind(installed: Optional[str], latest: Optional[str]) -> bool:
"""Whether `installed` is genuinely behind `latest`, comparing the FULL
release identity (so a mix build can legitimately be the latest) with a
base-build guard so a lagging GitHub /releases/latest can never read as an
update or a downgrade.
- identical tags -> not behind (clears the sticky banner post-update)
- higher base build on `latest` -> behind; lower -> NOT behind (downgrade guard)
- same base build: a different/new mix -> behind, but a bare ``bNNNN`` never
supersedes a mix build (extra PRs) at that base -> not behind
- non-bNNNN tags -> behind (plain inequality, since they already differ)
"""
if not installed or not latest:
return False
installed, latest = installed.strip(), latest.strip()
if installed == latest:
return False
ib, lb = parse_base_build(installed), parse_base_build(latest)
if ib is None or lb is None:
return True
if lb != ib:
return lb > ib
# Same base build, different tags: offer a mix (latest carries a suffix), but
# never offer a bare base over a mix install at the same base.
return latest != f"b{lb}"
def check_prebuilt_freshness(
binary_path: Optional[str],
*,
threshold_days: int = STALENESS_THRESHOLD_DAYS,
now: Optional[datetime] = None,
) -> dict:
"""Returns {has_marker, stale, installed_tag, latest_tag,
"""Returns {has_marker, stale, behind, installed_tag, latest_tag,
installed_at_utc, age_days, published_repo, threshold_days}.
stale = True iff installed != latest AND age >= threshold.
Fails open on missing data (stale stays False)."""
behind = installed genuinely older than latest (see is_behind).
stale = behind AND age >= threshold.
Fails open on missing data (behind/stale stay False)."""
out: dict = {
"has_marker": False,
"stale": False,
"behind": False,
"installed_tag": None,
"latest_tag": None,
"installed_at_utc": None,
@ -196,16 +256,25 @@ def check_prebuilt_freshness(
if not marker:
return out
out["has_marker"] = True
# Display prefers the normalized base ("tag"); comparison below prefers the
# full "release_tag" -- deliberately opposite fallbacks.
out["installed_tag"] = marker.get("tag") or marker.get("release_tag")
out["installed_at_utc"] = marker.get("installed_at_utc")
out["published_repo"] = marker.get("published_repo")
# The marker records both a normalized base tag ("tag", e.g. b9596) and the
# full release tag ("release_tag", e.g. b9596-mix-<sha>). Compare against the
# FULL identity, since GitHub /releases/latest returns the full tag_name --
# comparing the normalized base against the full latest is what produced the
# permanent "downgrade" banner on every mix release.
installed_full = marker.get("release_tag") or marker.get("tag")
repo = out["published_repo"]
if not repo or not out["installed_tag"]:
if not repo or not installed_full:
return out
latest = latest_published_release(repo)
out["latest_tag"] = latest
if not latest or latest == out["installed_tag"]:
out["behind"] = is_behind(installed_full, latest)
if not out["behind"]:
return out
installed_at = _parse_installed_at(out["installed_at_utc"])

View file

@ -59,10 +59,17 @@ _job: dict = {
"from_tag": None,
"to_tag": None,
"error": None,
"progress": None,
"started_at": None,
"finished_at": None,
}
# Matches the installer's download progress lines, e.g.
# "Downloading x.zip: 35.0% (12.3 MiB/35.1 MiB) at 8.2 MiB/s".
_PROGRESS_LINE_RE = re.compile(r"(\d+(?:\.\d+)?)%\s*\(")
# The download dominates the update; extract/validate fill the last slice.
_DOWNLOAD_PROGRESS_CEILING = 0.95
def _utcnow() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
@ -279,9 +286,10 @@ def get_update_status(*, force_refresh: bool = False) -> dict:
freshness = check_prebuilt_freshness(binary)
installed = freshness.get("installed_tag")
latest = freshness.get("latest_tag")
update_available = bool(
freshness.get("has_marker") and installed and latest and installed != latest
)
# `behind` compares the full release identity with a base-build guard, so a
# lagging /releases/latest or a mix-tagged latest can't show a false update
# (see llama_cpp_freshness.is_behind).
update_available = bool(freshness.get("has_marker") and freshness.get("behind"))
with _job_lock:
job = dict(_job)
@ -357,19 +365,55 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path
]
cmd.extend(_rocm_install_args(asset))
logger.info("llama update: installing", cmd = " ".join(cmd))
proc = subprocess.run(
# Stream the installer output so download percent lines feed
# job["progress"]; finer milestones via UNSLOTH_PROGRESS_PERCENT_STEP.
env = dict(os.environ, UNSLOTH_PROGRESS_PERCENT_STEP = "5")
proc = subprocess.Popen(
cmd,
capture_output = True,
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
timeout = _INSTALL_TIMEOUT_SECONDS,
env = env,
)
if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip()[-1500:]
raise RuntimeError(f"installer exited {proc.returncode}: {tail or 'no output'}")
timed_out = threading.Event()
# New UNSLOTH_PREBUILT_INFO.json is on disk; drop caches so the next
# status read reflects the freshly installed tag.
def _kill_on_timeout() -> None:
timed_out.set()
proc.kill()
watchdog = threading.Timer(_INSTALL_TIMEOUT_SECONDS, _kill_on_timeout)
watchdog.daemon = True
watchdog.start()
tail_lines: list[str] = []
try:
assert proc.stdout is not None
for line in proc.stdout:
tail_lines.append(line)
if len(tail_lines) > 80:
del tail_lines[0]
m = _PROGRESS_LINE_RE.search(line)
if m is None:
continue
fraction = min(float(m.group(1)) / 100.0, 1.0) * _DOWNLOAD_PROGRESS_CEILING
with _job_lock:
_job["progress"] = max(_job.get("progress") or 0.0, fraction)
returncode = proc.wait()
finally:
watchdog.cancel()
if timed_out.is_set():
raise RuntimeError(f"installer timed out after {_INSTALL_TIMEOUT_SECONDS}s")
if returncode != 0:
tail = "".join(tail_lines).strip()[-1500:]
raise RuntimeError(f"installer exited {returncode}: {tail or 'no output'}")
# New UNSLOTH_PREBUILT_INFO.json is on disk; drop in-memory caches and
# re-prime the 24h disk freshness cache with the true newest, so the
# banner can't linger on a stale same-base value after the swap.
reset_caches()
try:
latest_published_release(repo, force_refresh = True)
except Exception as exc: # pragma: no cover - network defensive
logger.debug("llama update: post-install freshness refresh failed", error = str(exc))
new_marker = read_install_marker(_find_binary())
new_tag = (new_marker or {}).get("tag") or (new_marker or {}).get("release_tag")
@ -382,6 +426,7 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path
),
to_tag = new_tag,
error = None,
progress = 1.0,
finished_at = _utcnow(),
)
logger.info("llama update: success", to_tag = new_tag)
@ -417,7 +462,24 @@ def start_update() -> dict:
"job": get_update_status()["job"],
}
# A job already in flight wins over any freshness re-check below (and skips
# its network call). The final lock block re-checks to close the TOCTOU.
with _job_lock:
if _job["state"] == _JOB_RUNNING:
return {"started": False, "reason": "already_running", "job": dict(_job)}
if marker:
# Mirror the detection guard: a direct POST or a stale banner must not
# start an install when the latest is not actually newer (force a fresh
# check so a stale 24h cache can't wrongly block a real update either).
status = get_update_status(force_refresh = True)
if not status.get("update_available"):
return {
"started": False,
"reason": "up_to_date",
"message": "The installed llama.cpp build is already at the latest prebuilt.",
"job": status["job"],
}
install_dir = _install_dir_for(binary)
repo = marker.get("published_repo") or DEFAULT_PUBLISHED_REPO
from_tag = marker.get("tag") or marker.get("release_tag")
@ -467,6 +529,7 @@ def start_update() -> dict:
from_tag = from_tag,
to_tag = None,
error = None,
progress = 0.0,
started_at = _utcnow(),
finished_at = None,
)
@ -491,6 +554,7 @@ def _reset_job_for_tests() -> None:
from_tag = None,
to_tag = None,
error = None,
progress = None,
started_at = None,
finished_at = None,
)

View file

@ -15,6 +15,7 @@ _DEV_VERSION = "dev"
_GIT_TIMEOUT_SECONDS = 1.0
_STUDIO_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$")
_GIT_DESCRIBE_SUFFIX_RE = re.compile(r"-\d+-g[0-9A-Fa-f]+(?:-dirty)?$")
_GIT_BRANCH_RE = re.compile(r"^[0-9A-Za-z._/-]+$")
_MAX_VERSION_LENGTH = 64
@ -71,6 +72,35 @@ def _exact_git_studio_tag(repo_root: Path) -> str | None:
return tag if is_valid_studio_release_version(tag) else None
def _git_branch(repo_root: Path) -> str | None:
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd = repo_root,
check = False,
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
text = True,
timeout = _GIT_TIMEOUT_SECONDS,
)
except (OSError, subprocess.TimeoutExpired):
return None
if result.returncode != 0:
return None
branch = result.stdout.strip()
# "HEAD" means detached, e.g. a tag or commit checkout.
if (
not branch
or branch == "HEAD"
or len(branch) > _MAX_VERSION_LENGTH
or _GIT_BRANCH_RE.fullmatch(branch) is None
):
return None
return branch
def get_studio_version(repo_root: Path | None = None) -> str:
"""Return the installed Studio release tag for display, or ``dev``.
@ -81,7 +111,10 @@ def get_studio_version(repo_root: Path | None = None) -> str:
if _is_source_checkout(resolved_repo_root):
git_tag = _exact_git_studio_tag(resolved_repo_root)
return git_tag if git_tag is not None else _DEV_VERSION
if git_tag is not None:
return git_tag
branch = _git_branch(resolved_repo_root)
return f"GitHub {branch}" if branch is not None else _DEV_VERSION
stamped_version = _studio_release_build.STUDIO_RELEASE_VERSION
if is_valid_studio_release_version(stamped_version):

View file

@ -60,6 +60,7 @@ import {
Logout05Icon,
MoreVerticalIcon,
Search01Icon,
PlusSignIcon,
PowerIcon,
PencilEdit02Icon,
LayoutAlignLeftIcon,
@ -558,7 +559,8 @@ export function AppSidebar() {
: "sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto";
const buttonClass = cn(
"sidebar-nav-btn h-[33px] cursor-pointer rounded-full pr-4 text-[14.5px] leading-[19px] tracking-nav font-medium",
variant === "project" ? "pl-[39px]" : "pl-3",
// pl-3.5 starts the title at the same x as the Recents label text.
variant === "project" ? "pl-[39px]" : "pl-3.5",
variant === "project"
? "group-hover/project-chat-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-8"
: "group-hover/recent-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8",
@ -837,7 +839,28 @@ export function AppSidebar() {
navigate({ to: "/projects" });
closeMobileIfOpen();
}}
/>
className="group/projects-item relative"
>
<button
type="button"
aria-label="New project"
onClick={(e) => {
e.stopPropagation();
setProjectCreateMoveTarget(null);
setProjectNameDraft("");
setCreatingProject(true);
}}
className="sidebar-row-action group-hover/projects-item:opacity-100 group-hover/projects-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto group-data-[collapsible=icon]:hidden"
>
<span className="sidebar-row-action-glyph">
<HugeiconsIcon
icon={PlusSignIcon}
strokeWidth={1.75}
className="size-4"
/>
</span>
</button>
</NavItem>
<NavItem
icon={DashboardCircleIcon}
label={t("shell.navigation.hub")}
@ -1065,7 +1088,7 @@ export function AppSidebar() {
className="!size-[32px]"
/>
</div>
<div className="flex flex-col gap-0.5 leading-tight group-data-[collapsible=icon]:hidden">
<div className="flex flex-col gap-px leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-heading text-[13.5px] tracking-[0.025em] dark:tracking-[0.04em] font-semibold text-nav-fg">{displayTitle}</span>
<span className="truncate text-[11.5px] tracking-nav text-muted-foreground">Unsloth</span>
</div>

View file

@ -3,9 +3,10 @@
import { Button } from "@/components/ui/button";
import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check";
import { useShowLlamaUpdateBanner } from "@/hooks/use-llama-update-pref";
import { toast } from "@/lib/toast";
import { AnimatePresence, motion } from "motion/react";
import { type ReactElement, useEffect, useRef } from "react";
import type { ReactElement } from "react";
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
@ -15,15 +16,19 @@ interface LlamaUpdateBannerProps {
/**
* Non-invasive "Update llama.cpp" affordance. Appears bottom-right ~1s after a
* newer prebuilt is detected and stays up until dismissed (click outside / X)
* or updated. Clicking Update swaps the prebuilt in place via POST /api/llama/update.
* newer prebuilt is detected and stays up until the user explicitly acts on it
* (X, Update, or Remind me later). Clicking Update swaps the prebuilt in place
* via POST /api/llama/update. Can be turned off entirely in Settings ->
* General -> Notifications (on by default).
*/
export function LlamaUpdateBanner({
enabled = true,
}: LlamaUpdateBannerProps): ReactElement | null {
const { status, visible, applying, apply, dismiss } = useLlamaUpdateCheck({
enabled,
});
const showBannerPref = useShowLlamaUpdateBanner();
const { status, visible, applying, apply, dismiss, snooze } =
useLlamaUpdateCheck({
enabled: enabled && showBannerPref,
});
async function handleUpdate() {
const result = await apply();
@ -40,30 +45,12 @@ export function LlamaUpdateBanner({
const show =
visible && status != null && (status.update_available || applying);
const bannerRef = useRef<HTMLDivElement>(null);
// Dismiss when the user clicks anything outside the banner. Kept off while an
// update is applying so the progress stays visible.
useEffect(() => {
if (!show || applying) return;
function onPointerDown(event: PointerEvent) {
if (
bannerRef.current &&
!bannerRef.current.contains(event.target as Node)
) {
dismiss();
}
}
document.addEventListener("pointerdown", onPointerDown, true);
return () =>
document.removeEventListener("pointerdown", onPointerDown, true);
}, [show, applying, dismiss]);
const updateProgress = status?.job.progress ?? null;
return (
<AnimatePresence>
{show ? (
<motion.div
ref={bannerRef}
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.97 }}
@ -71,12 +58,12 @@ export function LlamaUpdateBanner({
className="fixed bottom-4 right-4 z-[9998] w-[calc(100vw-2rem)] max-w-[340px]"
data-testid="llama-update-banner"
>
<div className="corner-squircle relative overflow-hidden border border-border/60 bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="relative overflow-hidden rounded-[24px] bg-white px-4 pb-[22px] pl-6 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]">
{applying ? null : (
<button
type="button"
onClick={dismiss}
className="absolute top-2.5 right-2.5 flex size-5 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
className="absolute top-2.5 right-3 flex size-6 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
aria-label="Dismiss llama.cpp update notification"
>
<svg
@ -97,32 +84,64 @@ export function LlamaUpdateBanner({
</button>
)}
<div className="flex items-center gap-2 pr-5">
<span className="text-base" aria-hidden="true">
🦥
</span>
<p className="text-sm font-medium text-foreground">
{applying ? "Updating llama.cpp..." : "New llama.cpp prebuilt"}
<div className="min-w-0 pr-6">
<p className="font-heading text-base font-medium text-foreground">
{applying ? "Updating llama.cpp..." : "New llama.cpp version"}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{status?.installed_tag ?? "unknown"} &rarr;{" "}
<span className="font-medium text-foreground">
{status?.latest_tag ?? ""}
</span>
</p>
</div>
<p className="mt-0.5 pl-7 text-xs text-muted-foreground">
{status?.installed_tag ?? "unknown"} &rarr;{" "}
<span className="font-medium text-foreground">
{status?.latest_tag ?? ""}
</span>
</p>
<div className="mt-2.5 pl-7">
<Button
size="sm"
className="corner-squircle"
onClick={handleUpdate}
disabled={applying}
data-testid="llama-update-button"
{applying ? (
<div
className="mb-1.5 mt-4 h-1 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-label="Updating llama.cpp"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={
updateProgress != null
? Math.round(updateProgress * 100)
: undefined
}
data-testid="llama-update-progress"
>
{applying ? "Updating..." : "Update llama.cpp"}
</Button>
</div>
{updateProgress != null && updateProgress > 0 ? (
<div
className="h-full rounded-full bg-primary transition-[width] duration-700 ease-out"
style={{ width: `${Math.round(updateProgress * 100)}%` }}
/>
) : (
// No percent yet (resolving the release): sweep until the
// first download progress arrives.
<div className="loading-bar-slide h-full w-1/3 rounded-full bg-primary" />
)}
</div>
) : (
<div className="mt-3 flex items-center gap-2">
<Button
size="sm"
className="h-auto rounded-full px-3.5 py-2 text-[13px]"
onClick={handleUpdate}
data-testid="llama-update-button"
>
Update
</Button>
<Button
size="sm"
variant="ghost"
className="h-auto rounded-full px-2.5 py-2 text-[13px] text-muted-foreground hover:text-foreground"
onClick={snooze}
data-testid="llama-update-snooze-button"
>
Remind me later
</Button>
</div>
)}
</div>
</motion.div>
) : null}

View file

@ -228,7 +228,7 @@ function RepairingContent({
</div>
<div className="mb-10 flex flex-col items-center gap-2">
<TealSpinner />
<p className="text-sm font-bold text-foreground">Updating existing Studio install...</p>
<p className="text-sm font-bold text-foreground">Updating existing Unsloth install...</p>
{latest && (
<p className="max-w-xs text-center text-xs text-muted-foreground">{latest}</p>
)}

View file

@ -98,9 +98,27 @@ function TooltipContent({
}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
variant?: TooltipVariant;
}) {
// Single-line compact tooltips render as a full pill; wrapped ones keep
// the squarer corners so tall pills do not look like capsules. A ref
// callback measures on mount: Radix mounts the portal content without
// re-rendering this wrapper, so an effect here would never see the node.
const measureRef = useCallback(
(el: HTMLDivElement | null) => {
if (!el || variant !== "default") return;
const cs = getComputedStyle(el);
const lineHeight = Number.parseFloat(cs.lineHeight) || 16;
const innerHeight =
el.clientHeight -
Number.parseFloat(cs.paddingTop) -
Number.parseFloat(cs.paddingBottom);
el.classList.toggle("rounded-full!", innerHeight < lineHeight * 1.5);
},
[variant],
);
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={measureRef}
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(

View file

@ -2,13 +2,16 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { usePlatformStore } from "@/config/env";
import { useWebUpdateCheck } from "@/hooks/use-web-update-check";
import { isTauri } from "@/lib/api-base";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { AnimatePresence, motion } from "motion/react";
import { type ReactElement, useEffect, useRef, useState } from "react";
const STUDIO_UPDATE_CMD = "unsloth studio update";
const STUDIO_INSTALL_UNIX_CMD =
"curl -fsSL https://unsloth.ai/install.sh | sh";
const STUDIO_INSTALL_WINDOWS_CMD = "irm https://unsloth.ai/install.ps1 | iex";
const RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog";
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
@ -20,6 +23,11 @@ export function WebUpdateBanner({
enabled = true,
}: WebUpdateBannerProps): ReactElement | null {
const { status, dismiss } = useWebUpdateCheck({ enabled });
const deviceType = usePlatformStore((s) => s.deviceType);
const installCmd =
deviceType === "windows"
? STUDIO_INSTALL_WINDOWS_CMD
: STUDIO_INSTALL_UNIX_CMD;
const [copiedVersion, setCopiedVersion] = useState<string | null>(null);
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -36,7 +44,7 @@ export function WebUpdateBanner({
}
async function handleCopyCommand() {
if (!(await copyToClipboard(STUDIO_UPDATE_CMD))) {
if (!(await copyToClipboard(installCmd))) {
return;
}
setCopiedVersion(status?.latestVersion ?? null);
@ -89,8 +97,8 @@ export function WebUpdateBanner({
Package update available: {status.latestVersion}
</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Installed package: {status.currentVersion}. To update Studio,
run this in your terminal, then restart Studio.
Installed package: {status.currentVersion}. To update Unsloth,
run this in your terminal, then restart Unsloth.
</p>
</div>
</div>

View file

@ -46,12 +46,28 @@ export async function fetchDeviceType(): Promise<DeviceType> {
if (fetched) return usePlatformStore.getState().deviceType;
try {
const res = await fetch(apiUrl("/api/health"));
// /api/health only reports the server's device_type to authed callers.
// Read the token from storage directly: importing features/auth here
// would be an import cycle (auth/session imports this store).
const token =
typeof window === "undefined"
? null
: localStorage.getItem("unsloth_auth_token");
const res = await fetch(apiUrl("/api/health"), {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
if (res.ok) {
const data = (await res.json()) as { device_type?: string; chat_only?: boolean };
const deviceType = data.device_type ?? detectLocalPlatform();
const chatOnly = data.chat_only ?? false;
usePlatformStore.setState({ deviceType, chatOnly, fetched: true });
// Cache only a server-reported platform. Unauthenticated responses fall
// back to the browser platform, which can differ from the host (WSL,
// SSH); keeping fetched=false retries once a token exists.
usePlatformStore.setState({
deviceType,
chatOnly,
fetched: data.device_type !== undefined,
});
return deviceType;
}
} catch {

View file

@ -175,7 +175,7 @@ export async function authFetch(
"You appear to be offline. Check your network connection and try again.",
);
}
throw new Error("Studio isn't running -- please relaunch it.");
throw new Error("Unsloth isn't running -- please relaunch it.");
}
throw err;
}

View file

@ -26,7 +26,7 @@ let pending: { promise: Promise<boolean>; force: boolean } | null = null;
let lastTauriAuthFailure: string | null = null;
const TAURI_AUTH_FAILURE_FALLBACK =
"Desktop authentication failed. Update or repair the managed Studio install, then restart Studio.";
"Desktop authentication failed. Update or repair the managed Unsloth install, then restart Unsloth.";
const BACKEND_NOT_READY_MESSAGE = "Backend is not ready";
function authFailureMessage(error: unknown): string {

View file

@ -18,7 +18,7 @@ const describeMediaError = (error: unknown): string => {
return "Dictation could not access the microphone.";
}
if (error.name === "NotAllowedError") {
return "Microphone access is blocked. Allow microphone access for this Studio page, then try again.";
return "Microphone access is blocked. Allow microphone access for this Unsloth page, then try again.";
}
if (error.name === "NotFoundError") {
return "No microphone was found for dictation.";
@ -31,7 +31,7 @@ const describeMediaError = (error: unknown): string => {
const describeSpeechError = (error: string, message?: string): string => {
if (error === "not-allowed") {
return "Speech recognition was blocked by the browser. Check microphone permissions for this Studio page.";
return "Speech recognition was blocked by the browser. Check microphone permissions for this Unsloth page.";
}
if (error === "service-not-allowed") {
return "Speech recognition is blocked by the browser speech service.";

View file

@ -1511,7 +1511,7 @@ export function ChatProvidersSettings({
<div className="flex min-w-0 flex-col gap-1">
<h1 className="font-heading text-lg font-semibold">Connections</h1>
<p className="text-xs leading-relaxed text-muted-foreground">
Manage model connections for chat through the Studio proxy.
Manage model connections for chat.
</p>
</div>
</header>

View file

@ -325,6 +325,7 @@ function CollapsibleSection({
label,
labelHref,
headerAction,
onLabelClick,
children,
defaultOpen = false,
first = false,
@ -342,6 +343,8 @@ function CollapsibleSection({
* nested in a button.
*/
headerAction?: ReactNode;
/** When set, clicking the label runs this instead of toggling collapse. */
onLabelClick?: () => void;
children?: ReactNode;
defaultOpen?: boolean;
first?: boolean;
@ -395,7 +398,7 @@ function CollapsibleSection({
<div className={headerClasses}>
<button
type="button"
onClick={toggle}
onClick={onLabelClick ?? toggle}
className="flex min-w-0 flex-1 cursor-pointer items-center text-left leading-none transition-colors hover:text-nav-fg"
>
<span className="leading-none">{label}</span>
@ -558,6 +561,9 @@ export function ChatSettingsPanel({
const [presetNameInput, setPresetNameInput] = useState(activePreset);
const [systemPromptEditorOpen, setSystemPromptEditorOpen] = useState(false);
const [systemPromptDraft, setSystemPromptDraft] = useState("");
// When the prompt overflows the inline box, clicking opens the popup editor.
const systemPromptBoxRef = useRef<HTMLTextAreaElement>(null);
const [systemPromptOverflows, setSystemPromptOverflows] = useState(false);
const [activePresetBaseline, setActivePresetBaseline] = useState(params);
const presets = useMemo(() => {
return getOrderedPresets(customPresets);
@ -762,6 +768,16 @@ export function ChatSettingsPanel({
}
}, [open]);
useEffect(() => {
const el = systemPromptBoxRef.current;
setSystemPromptOverflows(
params.systemPrompt.length > 0 &&
el != null &&
el.clientHeight > 0 &&
el.scrollHeight > el.clientHeight + 1,
);
}, [params.systemPrompt, open]);
const settingsScrollRef = useRef<HTMLDivElement>(null);
const settingsContent = (
@ -1119,7 +1135,7 @@ export function ChatSettingsPanel({
/>
<InputGroupAddon
align="inline-end"
className="min-h-0 shrink-0 gap-0 self-stretch border-0 py-0 pl-0 !pr-1 has-[>button]:mr-0"
className="min-h-0 shrink-0 gap-0 self-stretch border-0 py-0 pl-0 !pr-1 has-[>button]:mr-0 !cursor-pointer"
>
<span
className="!h-7 min-h-7 !w-7 min-w-7 shrink-0 self-center inline-flex items-center justify-center rounded-full border-0 px-0 text-[#a0a097] dark:text-nav-fg pointer-events-none"
@ -1300,6 +1316,7 @@ export function ChatSettingsPanel({
<CollapsibleSection
label="System Prompt"
defaultOpen={true}
onLabelClick={openSystemPromptEditor}
headerAction={
<Tooltip>
<TooltipPrimitive.Trigger asChild>
@ -1326,22 +1343,36 @@ export function ChatSettingsPanel({
</Tooltip>
}
>
<button
type="button"
onClick={openSystemPromptEditor}
aria-label="Edit system prompt"
{/* Rounded wrapper clips overflowing text and the scrollbar. */}
<div
className={cn(
"panel-text-surface -mt-1 flex w-full h-20 overflow-hidden cursor-pointer items-start px-3.5 py-2.5 text-left text-[13px] font-medium leading-relaxed corner-squircle focus-visible:outline-none focus-visible:border-ring focus-visible:ring-[1px] focus-visible:ring-ring/40",
params.systemPrompt
? "text-nav-fg"
: "text-muted-foreground",
"panel-text-surface -mt-1 h-20 w-full overflow-hidden corner-squircle",
systemPromptOverflows && "cursor-pointer",
)}
>
<span className="block line-clamp-3 whitespace-pre-wrap break-words">
{params.systemPrompt ||
"Example: You are a helpful assistant..."}
</span>
</button>
<textarea
ref={systemPromptBoxRef}
value={params.systemPrompt}
onChange={(e) => set("systemPrompt")(e.target.value)}
onMouseDown={(e) => {
// Overflowing prompt: click opens the popup editor instead.
// While focused, clicks still move the caret normally.
if (
systemPromptOverflows &&
document.activeElement !== e.currentTarget
) {
e.preventDefault();
openSystemPromptEditor();
}
}}
placeholder="Example: You are a helpful assistant..."
aria-label="System prompt"
className={cn(
"block size-full resize-none bg-transparent px-3.5 py-2.5 text-left text-[13px] font-medium leading-relaxed text-nav-fg outline-none placeholder:text-muted-foreground",
systemPromptOverflows && "cursor-pointer",
)}
/>
</div>
</CollapsibleSection>
<CollapsibleSection label="Sampling" defaultOpen={true}>

View file

@ -28,7 +28,7 @@ export function buildChatTourSteps({
body: (
<>
This selects whats loaded for inference. Hub = base models. Fine-tuned
= trained Studio outputs, including LoRA adapters and full finetunes.
= trained Unsloth outputs, including LoRA adapters and full finetunes.
</>
),
},
@ -38,7 +38,7 @@ export function buildChatTourSteps({
title: "Two tabs",
body: (
<>
Hub: search Hugging Face models. Fine-tuned: local Studio outputs youve
Hub: search Hugging Face models. Fine-tuned: local Unsloth outputs youve
trained or exported. If results look off, compare base vs fine-tuned
outputs to see what changed.
</>

View file

@ -134,7 +134,7 @@ export function isExpectedBackgroundChatStorageError(error: unknown): boolean {
(error.message === "Invalid or expired token" ||
error.message === "Not authenticated" ||
error.message === "Request failed (401)" ||
error.message === "Studio isn't running -- please relaunch it.")
error.message === "Unsloth isn't running -- please relaunch it.")
);
}

View file

@ -12,14 +12,14 @@ function parseErrorText(status: number, body: unknown): string {
const detail = (body as { detail?: unknown }).detail;
const formatted = formatFastApiDetail(detail);
if (status === 405) {
return `${formatted || "Method Not Allowed"} - the Studio backend did not accept this API method. Restart Studio so the frontend and backend are on the same build.`;
return `${formatted || "Method Not Allowed"} - the Unsloth backend did not accept this API method. Restart Unsloth so the frontend and backend are on the same build.`;
}
if (formatted) return formatted;
const message = (body as { message?: unknown }).message;
if (typeof message === "string" && message) return message;
}
if (status === 405) {
return "Method Not Allowed - the Studio backend did not accept this API method. Restart Studio so the frontend and backend are on the same build.";
return "Method Not Allowed - the Unsloth backend did not accept this API method. Restart Unsloth so the frontend and backend are on the same build.";
}
return `Request failed (${status})`;
}
@ -137,7 +137,7 @@ const DOWNLOAD_TRANSPORT_CAPABILITIES_FALLBACK: DownloadTransportCapabilities =
http: { available: true, reason: null },
xet: {
available: null,
reason: "Couldn't verify Xet support with the Studio backend.",
reason: "Couldn't verify Xet support with the Unsloth backend.",
},
};
let downloadTransportCapabilitiesCache: DownloadTransportCapabilities | null =

View file

@ -184,7 +184,7 @@ export async function effectiveTransportMode(
return preferred;
}
const reason =
capabilities.xet.reason ?? "Studio will use HTTP downloads instead.";
capabilities.xet.reason ?? "Unsloth will use HTTP downloads instead.";
if (lastXetUnavailableWarningReason !== reason) {
lastXetUnavailableWarningReason = reason;
toast.warning("Xet download transport unavailable", {

View file

@ -45,7 +45,7 @@ function formatGitHubSourceMessage(execution: RecipeExecutionRecord): string {
return "Collecting repository threads before rows are available.";
}
if (source.status === "rate_limited") {
return source.message ?? "Waiting for GitHub rate limit. Studio will resume automatically.";
return source.message ?? "Waiting for GitHub rate limit. Unsloth will resume automatically.";
}
return source.message ?? "Collecting repository threads before rows are available.";
}

View file

@ -41,7 +41,7 @@ function formatSourceMessage(execution: RecipeExecutionRecord): string {
typeof source.retry_after_sec === "number" && source.retry_after_sec > 0
? ` Waiting ~${formatMetricValue(source.retry_after_sec)}s.`
: "";
return `Waiting for GitHub rate limit. Studio will resume automatically.${wait}`;
return `Waiting for GitHub rate limit. Unsloth will resume automatically.${wait}`;
}
return source.message ?? "Crawling GitHub source.";
}

View file

@ -68,7 +68,7 @@ function formatGitHubSourceSummary(
typeof source.retry_after_sec === "number" && source.retry_after_sec > 0
? ` ~${formatMetricValue(source.retry_after_sec)}s`
: "";
return `Waiting for GitHub rate limit${wait}. Studio will resume automatically.`;
return `Waiting for GitHub rate limit${wait}. Unsloth will resume automatically.`;
}
if (source.status === "retrying") {
return source.message ?? "GitHub request failed; retrying automatically.";

View file

@ -334,8 +334,8 @@ export function GithubRepoSeedForm({
/>
<p id={tokenHelpId} className="text-xs text-muted-foreground">
{usingEnvToken
? "Studio detected a server env token, so saved/shared recipes can leave this blank."
: "Blank is safest for saved/shared recipes because Studio will read the server environment at run time."}
? "Unsloth detected a server env token, so saved/shared recipes can leave this blank."
: "Blank is safest for saved/shared recipes because Unsloth will read the server environment at run time."}
</p>
{hasToken && (
<p className="rounded-md bg-amber-500/10 px-2 py-1.5 text-xs text-amber-700 dark:text-amber-300">
@ -450,7 +450,7 @@ export function GithubRepoSeedForm({
</fieldset>
<p className="text-xs text-muted-foreground">
Backed by Studio's built-in <code>github_repo</code> seed reader. Large
Backed by Unsloth's built-in <code>github_repo</code> seed reader. Large
repos can take minutes, so start with small limits for previews.
</p>
</div>

View file

@ -4,21 +4,29 @@
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { useT } from "@/i18n";
import { cn } from "@/lib/utils";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import {
ArrowUpRight01Icon,
Copy01Icon,
Tick02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ReactElement } from "react";
import { useEffect, useRef, useState } from "react";
const STUDIO_UPDATE_CMD = "unsloth studio update";
const STUDIO_UPDATE_FALLBACK_UNIX_CMD =
const STUDIO_INSTALL_UNIX_CMD =
"curl -fsSL https://unsloth.ai/install.sh | sh";
const STUDIO_UPDATE_FALLBACK_WINDOWS_CMD =
"irm https://unsloth.ai/install.ps1 | iex";
const STUDIO_INSTALL_WINDOWS_CMD = "irm https://unsloth.ai/install.ps1 | iex";
const STUDIO_LOCAL_PULL_CMD = "git pull --ff-only";
const STUDIO_LOCAL_UPDATE_CMD = "unsloth studio update --local";
const STUDIO_LOCAL_FALLBACK_UNIX_CMD = "./install.sh --local";
const STUDIO_LOCAL_FALLBACK_WINDOWS_CMD = ".\\install.ps1 --local";
const STUDIO_LOCAL_INSTALL_UNIX_CMD = "./install.sh --local";
const STUDIO_LOCAL_INSTALL_WINDOWS_CMD = ".\\install.ps1 --local";
const DOCS_INSTALL_URL = "https://unsloth.ai/docs/get-started/install";
const DOCS_UPDATING_URL =
"https://unsloth.ai/docs/get-started/install/updating";
const DOCS_MAC_URL = "https://unsloth.ai/docs/get-started/install/mac";
const DOCS_WINDOWS_URL =
"https://unsloth.ai/docs/get-started/install/windows-installation";
export type UpdateShell = "windows" | "unix";
export type UpdateInstallSource =
@ -30,15 +38,6 @@ export type UpdateInstallSource =
| "unknown";
type UpdateInstallSourceState = UpdateInstallSource | "loading";
function getStudioUpdateInstructionLine(
shell: UpdateShell,
t: ReturnType<typeof useT>,
): string {
return shell === "windows"
? t("settings.about.update.openPowerShell")
: t("settings.about.update.openTerminal");
}
function isLocalInstallSource(
installSource?: UpdateInstallSourceState | null,
): boolean {
@ -128,6 +127,77 @@ function CopyableCommand({
);
}
function DocsLink({
href,
label,
}: {
href: string;
label: string;
}): ReactElement {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-muted-foreground hover:text-foreground"
>
{label}
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
</a>
);
}
function UpdateDocsLinks(): ReactElement {
const t = useT();
return (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.docs")}
<DocsLink
href={DOCS_INSTALL_URL}
label={t("settings.about.update.docsInstall")}
/>
<DocsLink
href={DOCS_UPDATING_URL}
label={t("settings.about.update.docsUpdating")}
/>
<DocsLink
href={DOCS_MAC_URL}
label={t("settings.about.update.docsMac")}
/>
<DocsLink
href={DOCS_WINDOWS_URL}
label={t("settings.about.update.docsWindows")}
/>
</p>
);
}
function ShellToggleButton({
active,
label,
onClick,
}: {
active: boolean;
label: string;
onClick: () => void;
}): ReactElement {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
"rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors",
active
? "bg-foreground/[0.08] text-foreground dark:bg-white/[0.12]"
: "text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
);
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: keep source-specific update guidance in one component so the command matrix stays visible.
export function UpdateStudioInstructions({
className,
@ -145,6 +215,9 @@ export function UpdateStudioInstructions({
const shell = shellOverride ?? defaultShell;
const prefersReducedMotion = useReducedMotion();
const windows = shell === "windows";
// null means the desktop app: its bundled backend updates through the
// built-in updater, so terminal commands would target the wrong install.
const desktopManaged = installSource === null;
const localInstallSource = isLocalInstallSource(installSource);
const checkoutInstallSource =
installSource === "editable" || installSource === "local_repo";
@ -163,6 +236,22 @@ export function UpdateStudioInstructions({
? { opacity: 1 }
: { opacity: 0, y: -2 };
if (desktopManaged) {
return (
<div className={cn("flex flex-col gap-3", className)}>
{showTitle ? (
<p className="shrink-0 whitespace-nowrap text-sm font-semibold font-heading">
{t("settings.about.update.title")}
</p>
) : null}
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.desktopManaged")}
</p>
<UpdateDocsLinks />
</div>
);
}
return (
<div className={cn("flex flex-col gap-3", className)}>
<div
@ -176,42 +265,55 @@ export function UpdateStudioInstructions({
{t("settings.about.update.title")}
</p>
) : null}
<div className="flex shrink-0 items-center gap-0.5 text-[11px]">
<button
type="button"
onClick={() => setShellOverride("windows")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
windows
? "text-foreground"
: "text-muted-foreground hover:text-emerald-600",
)}
aria-pressed={windows}
>
Windows
</button>
<span className="text-border">/</span>
<button
type="button"
<div className="flex shrink-0 items-center gap-1.5">
<ShellToggleButton
active={!windows}
label="MacOS / Linux"
onClick={() => setShellOverride("unix")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
windows
? "text-muted-foreground hover:text-emerald-600"
: "text-foreground",
)}
aria-pressed={!windows}
>
macOS/Linux
</button>
/>
<ShellToggleButton
active={windows}
label="Windows"
onClick={() => setShellOverride("windows")}
/>
</div>
</div>
{loadingInstallSource ? (
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.checkingInstall")}
</p>
) : localInstallSource ? (
) : (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.installIntro")}
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`install-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
<CopyableCommand
command={
windows ? STUDIO_INSTALL_WINDOWS_CMD : STUDIO_INSTALL_UNIX_CMD
}
copyLabel={
windows
? t("settings.about.update.installCommandWindows")
: t("settings.about.update.installCommandUnix")
}
/>
</motion.div>
</AnimatePresence>
</>
)}
{loadingInstallSource ? null : localInstallSource ? (
<>
<p className="text-xs font-semibold text-foreground">
{t("settings.about.update.localUpdateHeading")}
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.localInstallDetected")}
</p>
@ -224,16 +326,9 @@ export function UpdateStudioInstructions({
command={STUDIO_LOCAL_PULL_CMD}
copyLabel={t("settings.about.update.gitPullCommand")}
/>
<CopyableCommand
command={STUDIO_LOCAL_UPDATE_CMD}
copyLabel={t("settings.about.update.localUpdateCommand")}
/>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.localInstallerFallback")}
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`local-fallback-${shell}`}
key={`local-installer-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
@ -242,8 +337,8 @@ export function UpdateStudioInstructions({
<CopyableCommand
command={
windows
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
? STUDIO_LOCAL_INSTALL_WINDOWS_CMD
: STUDIO_LOCAL_INSTALL_UNIX_CMD
}
copyLabel={t("settings.about.update.localInstallerCommand")}
/>
@ -261,7 +356,7 @@ export function UpdateStudioInstructions({
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`source-fallback-${shell}`}
key={`source-installer-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
@ -270,8 +365,8 @@ export function UpdateStudioInstructions({
<CopyableCommand
command={
windows
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
? STUDIO_LOCAL_INSTALL_WINDOWS_CMD
: STUDIO_LOCAL_INSTALL_UNIX_CMD
}
copyLabel={t("settings.about.update.localInstallerCommand")}
/>
@ -282,54 +377,22 @@ export function UpdateStudioInstructions({
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.restartAfterUpdate")}
</p>
<UpdateDocsLinks />
</>
) : unknownInstallSource ? (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.unknownInstall")}
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.curlOrPypi")}
<p className="text-xs font-semibold text-foreground">
{t("settings.about.update.localUpdateHeading")}
</p>
<CopyableCommand
command={STUDIO_UPDATE_CMD}
copyLabel={t("settings.about.update.updateCommand")}
/>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.localCheckout")}
</p>
<CopyableCommand
command={STUDIO_LOCAL_UPDATE_CMD}
copyLabel={t("settings.about.update.localUpdateCommand")}
/>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.restartAfterUpdate")}
</p>
</>
) : (
<>
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={`instruction-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
className="text-xs text-muted-foreground leading-relaxed"
>
{getStudioUpdateInstructionLine(shell, t)}
</motion.p>
</AnimatePresence>
<CopyableCommand
command={STUDIO_UPDATE_CMD}
copyLabel={t("settings.about.update.updateCommand")}
/>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.fallbackInstruction")}
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`fallback-${shell}`}
key={`local-installer-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
@ -338,16 +401,24 @@ export function UpdateStudioInstructions({
<CopyableCommand
command={
windows
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
? STUDIO_LOCAL_INSTALL_WINDOWS_CMD
: STUDIO_LOCAL_INSTALL_UNIX_CMD
}
copyLabel={t("settings.about.update.fallbackCommand")}
copyLabel={t("settings.about.update.localInstallerCommand")}
/>
</motion.div>
</AnimatePresence>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.restartAfterUpdate")}
</p>
<UpdateDocsLinks />
</>
) : (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.restartAfterUpdate")}
</p>
<UpdateDocsLinks />
</>
)}
</div>

View file

@ -142,7 +142,7 @@ export function AboutTab() {
</p>
</header>
<SettingsSection title="Studio">
<SettingsSection title="Unsloth">
<SettingsRow label={t("settings.about.studioVersion")}>
<code className="font-mono text-xs text-muted-foreground">
{studioVersion}
@ -207,6 +207,37 @@ export function AboutTab() {
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.about.license.sectionTitle")}>
<SettingsRow
label={t("settings.about.license.studioLabel")}
description={t("settings.about.license.studioDescription")}
>
<a
href="https://github.com/unslothai/unsloth/blob/main/studio/LICENSE.AGPL-3.0"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-mono text-xs font-medium text-muted-foreground hover:text-foreground"
>
{t("settings.about.license.studioLicense")}
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
</a>
</SettingsRow>
<SettingsRow
label={t("settings.about.license.libraryLabel")}
description={t("settings.about.license.libraryDescription")}
>
<a
href="https://github.com/unslothai/unsloth/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-mono text-xs font-medium text-muted-foreground hover:text-foreground"
>
{t("settings.about.license.libraryLicense")}
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
</a>
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.about.dangerZone")}>
<SettingsRow
destructive={true}

View file

@ -15,6 +15,10 @@ import { Switch } from "@/components/ui/switch";
import { usePlatformStore } from "@/config/env";
import { resetOnboardingDone } from "@/features/auth";
import { useChatRuntimeStore } from "@/features/chat";
import {
setShowLlamaUpdateBanner,
useShowLlamaUpdateBanner,
} from "@/hooks/use-llama-update-pref";
import {
loadHelperPrecacheSettings,
updateHelperPrecacheSettings,
@ -68,6 +72,8 @@ const PREFS_KEYS: string[] = [
"unsloth_user_profile",
// Guided tour flags
"tour:studio:v1",
// Update notifications
"unsloth_show_llama_update_banner",
];
// Set by resetAllPrefs so the unmount-commit effect skips writing back the
@ -106,6 +112,7 @@ export function GeneralTab() {
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}`;
const [draftToken, setDraftToken] = useState(hfToken ?? "");
@ -315,6 +322,20 @@ export function GeneralTab() {
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.general.notifications.sectionTitle")}>
<SettingsRow
label={t("settings.general.notifications.showLlamaUpdates")}
description={t(
"settings.general.notifications.showLlamaUpdatesDescription",
)}
>
<Switch
checked={showLlamaUpdates}
onCheckedChange={setShowLlamaUpdateBanner}
/>
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.general.uploads.sectionTitle")}>
<SettingsRow
label={t("settings.general.uploads.maxUploadSize")}

View file

@ -9,7 +9,7 @@ export const studioNavStep: TourStep = {
title: "Quick orientation",
body: (
<>
Studio: pick base model, dataset, hyperparams, then start training. After
Unsloth: pick base model, dataset, hyperparams, then start training. After
you start, youll see a Training view with live loss/metrics. Chat is for
testing base vs LoRA adapters. Export packages checkpoints for deployment.{" "}
<ReadMore href="https://unsloth.ai/docs/get-started/fine-tuning-for-beginners" />

View file

@ -5,11 +5,14 @@ import { authFetch, getAuthToken } from "@/features/auth";
import { useCallback, useEffect, useRef, useState } from "react";
// First check shortly after load, then re-surface as an hourly reminder. The
// banner stays up until the user dismisses it (click outside / X) or updates.
// banner stays up until the user explicitly acts on it (X, Update, or
// Remind me later).
const FIRST_CHECK_DELAY_MS = 1000;
const REMINDER_INTERVAL_MS = 60 * 60 * 1000; // ~1 hour
// "Remind me later" re-surfaces sooner than the hourly reminder.
const SNOOZE_DELAY_MS = 15 * 60 * 1000; // ~15 minutes
// While an update is applying, poll the job state at this cadence.
const JOB_POLL_INTERVAL_MS = 3000;
const JOB_POLL_INTERVAL_MS = 1500;
export interface LlamaUpdateJob {
state: "idle" | "running" | "success" | "error";
@ -17,6 +20,8 @@ export interface LlamaUpdateJob {
from_tag: string | null;
to_tag: string | null;
error: string | null;
// Download fraction (0..1) while running, 1 on success, null when unknown.
progress: number | null;
}
export interface LlamaUpdateStatus {
@ -42,6 +47,7 @@ function parseStatus(value: unknown): LlamaUpdateStatus | null {
from_tag: typeof job.from_tag === "string" ? job.from_tag : null,
to_tag: typeof job.to_tag === "string" ? job.to_tag : null,
error: typeof job.error === "string" ? job.error : null,
progress: typeof job.progress === "number" ? job.progress : null,
},
};
}
@ -73,9 +79,10 @@ export interface LlamaApplyResult {
/**
* Polls the backend for a newer llama.cpp prebuilt. When one exists, `visible`
* becomes true ~1s after load and stays up until the user dismisses it (click
* outside / X) or updates; it re-surfaces every ~hour as a reminder. `apply()`
* triggers the in-place swap and tracks the job.
* becomes true ~1s after load and stays up until the user dismisses it (X),
* snoozes it ("Remind me later", ~15 min), or updates; it re-surfaces every
* ~hour as a reminder. `apply()` triggers the in-place swap and tracks the
* job.
*/
export function useLlamaUpdateCheck({
enabled = true,
@ -84,6 +91,7 @@ export function useLlamaUpdateCheck({
const [visible, setVisible] = useState(false);
const [applying, setApplying] = useState(false);
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const snoozeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearPollTimer = useCallback(() => {
if (pollTimer.current) {
@ -161,6 +169,10 @@ export function useLlamaUpdateCheck({
clearTimeout(firstTimer);
clearInterval(reminder);
clearPollTimer();
if (snoozeTimer.current) {
clearTimeout(snoozeTimer.current);
snoozeTimer.current = null;
}
};
}, [enabled, surfaceIfAvailable, clearPollTimer]);
@ -168,6 +180,16 @@ export function useLlamaUpdateCheck({
setVisible(false);
}, []);
// Hide now, re-check and re-surface after SNOOZE_DELAY_MS.
const snooze = useCallback(() => {
setVisible(false);
if (snoozeTimer.current) clearTimeout(snoozeTimer.current);
snoozeTimer.current = setTimeout(() => {
snoozeTimer.current = null;
fetchStatus().then(surfaceIfAvailable);
}, SNOOZE_DELAY_MS);
}, [surfaceIfAvailable]);
const apply = useCallback(async (): Promise<LlamaApplyResult> => {
if (applying) return { ok: false, error: "already running" };
setApplying(true);
@ -219,5 +241,6 @@ export function useLlamaUpdateCheck({
applying,
apply,
dismiss,
snooze,
};
}

View file

@ -0,0 +1,49 @@
// 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 { useSyncExternalStore } from "react";
// Whether the llama.cpp update banner may appear. On by default; only an
// explicit "false" (Settings -> General -> Notifications) disables it.
const STORAGE_KEY = "unsloth_show_llama_update_banner";
const listeners = new Set<() => void>();
export function getShowLlamaUpdateBanner(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) !== "false";
} catch {
return true;
}
}
export function setShowLlamaUpdateBanner(show: boolean): void {
try {
if (show) {
// Remove rather than store "true" so the default stays on.
localStorage.removeItem(STORAGE_KEY);
} else {
localStorage.setItem(STORAGE_KEY, "false");
}
} catch {
// storage unavailable
}
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
// Sync toggles made in another tab.
const onStorage = (event: StorageEvent) => {
if (event.key === STORAGE_KEY) listener();
};
window.addEventListener("storage", onStorage);
return () => {
listeners.delete(listener);
window.removeEventListener("storage", onStorage);
};
}
export function useShowLlamaUpdateBanner(): boolean {
return useSyncExternalStore(subscribe, getShowLlamaUpdateBanner);
}

View file

@ -56,23 +56,23 @@ function wait(ms: number) {
function externalConflictMessage(preflight: DesktopPreflightResult) {
if (preflight.reason === "desktop_owned_backend_active") {
return preflight.port
? `A desktop-owned Studio server for this install is already running on port ${preflight.port}. Quit the other desktop app instance, then try again.`
: "A desktop-owned Studio server for this install is already running. Quit the other desktop app instance, then try again.";
? `A desktop-owned Unsloth server for this install is already running on port ${preflight.port}. Quit the other desktop app instance, then try again.`
: "A desktop-owned Unsloth server for this install is already running. Quit the other desktop app instance, then try again.";
}
if (preflight.reason === "desktop_owned_backend_starting") {
return "The desktop-owned Studio backend is still starting. Wait a moment, then try again.";
return "The desktop-owned Unsloth backend is still starting. Wait a moment, then try again.";
}
if (preflight.reason?.startsWith("desktop_owned_backend_unmanageable:")) {
return preflight.port
? `A desktop-owned Studio backend on port ${preflight.port} cannot be safely controlled by this desktop app. Stop that backend, then reopen Studio.`
: "A desktop-owned Studio backend cannot be safely controlled by this desktop app. Stop that backend, then reopen Studio.";
? `A desktop-owned Unsloth backend on port ${preflight.port} cannot be safely controlled by this desktop app. Stop that backend, then reopen Unsloth.`
: "A desktop-owned Unsloth backend cannot be safely controlled by this desktop app. Stop that backend, then reopen Unsloth.";
}
return preflight.port
? `A Studio server for this install is already running from a terminal on port ${preflight.port}. Stop that server, or run \`unsloth studio update\` from that terminal before using the desktop app.`
: "A Studio server for this install is already running from a terminal. Stop that server, or run `unsloth studio update` from that terminal before using the desktop app.";
? `A Unsloth server for this install is already running from a terminal on port ${preflight.port}. Stop that server, or run \`unsloth studio update\` from that terminal before using the desktop app.`
: "A Unsloth server for this install is already running from a terminal. Stop that server, or run `unsloth studio update` from that terminal before using the desktop app.";
}
async function waitForManagedServerReady(
@ -248,8 +248,8 @@ export function useTauriBackend() {
} else {
setBackendError(
preflight.disposition === "owned_stale"
? "Desktop-owned Studio backend is too old for this desktop app. Run `unsloth studio update`, then restart Studio."
: "Managed Studio install is too old. Run `unsloth studio update`.",
? "Desktop-owned Unsloth backend is too old for this desktop app. Run `unsloth studio update`, then restart Unsloth."
: "Managed Unsloth install is too old. Run `unsloth studio update`.",
);
}
return;
@ -304,7 +304,7 @@ export function useTauriBackend() {
if (msg.includes("already running")) {
startingRef.current = false;
setBackendError(
"Managed server is already running but did not report a port. Restart Studio and try again.",
"Managed server is already running but did not report a port. Restart Unsloth and try again.",
);
return;
}
@ -596,7 +596,7 @@ export function useTauriBackend() {
const detail =
event instanceof CustomEvent && typeof event.detail === "string"
? event.detail
: "Desktop authentication failed. Update or repair the managed Studio install, then restart Studio.";
: "Desktop authentication failed. Update or repair the managed Unsloth install, then restart Unsloth.";
setAuthFailure(detail);
};
window.addEventListener("tauri-auth-failed", onAuthFailed);

View file

@ -83,7 +83,7 @@ export const en = {
title: "Settings",
dialog: {
title: "Settings",
description: "Manage your Unsloth Studio preferences.",
description: "Manage your Unsloth preferences.",
closeAriaLabel: "Close settings",
},
tabs: {
@ -93,11 +93,11 @@ export const en = {
chat: "Chat",
connections: "Connections",
apiKeys: "API",
about: "Help",
about: "About",
},
general: {
title: "General",
description: "Global preferences for Unsloth Studio.",
description: "Global preferences for Unsloth.",
account: "Account",
huggingFaceToken: "Hugging Face token",
huggingFaceTokenDescription:
@ -112,38 +112,44 @@ export const en = {
sectionTitle: "Helper LLM",
preloadOnStartup: "Pre-cache Helper LLM on startup",
preloadOnStartupDescription:
"Download and cache the AI Assist helper model in the background when Studio starts. Off by default; AI Assist can still download it on demand when clicked.",
"Download the AI Assist helper model in the background on startup. Off by default; AI Assist can still fetch it on demand.",
disabledByEnv:
"Disabled by UNSLOTH_HELPER_MODEL_DISABLE in the backend environment.",
loadError: "Failed to load Helper LLM settings.",
saveError: "Failed to save Helper LLM settings.",
},
notifications: {
sectionTitle: "Notifications",
showLlamaUpdates: "llama.cpp update notifications",
showLlamaUpdatesDescription:
"Notify when a newer llama.cpp build is available. Turn off if you only train.",
},
gettingStarted: "Getting started",
startOnboarding: "Start onboarding",
startOnboardingDescription:
"Open the setup wizard again without changing your account.",
"Reopen the setup wizard without changing your account.",
startOnboardingAction: "Start onboarding",
uploads: {
sectionTitle: "Uploads",
maxUploadSize: "Training dataset upload cap",
maxUploadSizeDescription:
"Applies to training dataset uploads. Default is {defaultSize} MB.",
"Default is {defaultSize} MB.",
},
resetPreferences: {
sectionTitle: "Danger zone",
label: "Reset all local preferences",
description:
"Clears local-only preferences. Chats, API access, and DB-backed settings are not affected.",
"Clears local-only preferences. Chats, API access, and DB-backed settings are kept.",
action: "Reset preferences",
confirmTitle: "Reset all local preferences?",
confirmDescription:
"This clears local-only preferences, then reloads Studio. Chats, API access, and DB-backed settings are not affected.",
"Clears local-only preferences and reloads Unsloth. Chats, API access, and DB-backed settings are kept.",
confirmAction: "Reset and reload",
},
},
profile: {
title: "Profile",
description: "Update how your profile appears in Studio.",
description: "How your profile appears in Unsloth.",
changePicture: "Change profile picture",
displayName: "Display name",
nameSaved: "Profile name saved",
@ -163,7 +169,7 @@ export const en = {
theme: {
title: "Theme",
label: "Color scheme",
description: "Choose light, dark, or follow your system.",
description: "Light, dark, or follow your system.",
system: "System",
light: "Light",
dark: "Dark",
@ -171,7 +177,7 @@ export const en = {
language: {
title: "Language",
label: "Display language",
description: "Choose the language used by Studio.",
description: "The language used by Unsloth.",
},
layout: {
title: "Layout",
@ -182,25 +188,25 @@ export const en = {
},
chat: {
title: "Chat",
description: "Manage your chat history stored on this device.",
description: "Manage chat history stored on this device.",
artifacts: {
title: "Artifacts",
collapseHtmlBlocks: "Collapse HTML blocks",
collapseHtmlBlocksDescription:
"Artifacts mode collapses full HTML fallback automatically. Turn this on to also collapse full fenced HTML documents when Artifacts is off.",
"Artifacts mode collapses full HTML automatically. Turn on to also collapse fenced HTML documents when Artifacts is off.",
allowNetworkAccess: "Allow artifact network access",
allowNetworkAccessDescription:
"Let artifact previews load scripts, styles, fonts, media, fetch, and WebSocket resources from HTTP(S) CDNs. Keep off for fully offline previews.",
"Let artifact previews load scripts, styles, fonts, media, and network resources from CDNs. Keep off for fully offline previews.",
},
data: "Data",
exportHistory: "Export chat history",
exportHistoryDescription:
"Download all chats and messages as a JSON file.",
"Download all chats and messages as JSON.",
exportAction: "Export",
exportingAction: "Exporting...",
exportConversations: "Export Recents and Projects",
exportConversationsDescription:
"Download Recents only, or Recents plus project chats, as Raw JSONL, CSV, or ShareGPT JSONL, combined or one file per chat.",
"Download Recents or Recents plus project chats as Raw JSONL, CSV, or ShareGPT JSONL, combined or per chat.",
exportConversationsAction: "Export",
exportScopeRecents: "Recents",
exportScopeAll: "Recents + Projects",
@ -208,14 +214,14 @@ export const en = {
exportPerChatSuffix: "(per chat)",
importChats: "Import chats",
importChatsDescription:
"Add conversations from a JSONL, NDJSON, or CSV export to Recents.",
"Import a JSONL, NDJSON, or CSV export into Recents.",
importChatsAction: "Import",
importNoConversations: "No conversations found in file.",
importedOneChat: "Imported 1 conversation to Recents.",
importedChatCount: "Imported {count} conversations to Recents.",
importFailed: "Import failed.",
clearHistory: "Clear chat history",
clearHistoryDescription: "Delete local chat history from this device.",
clearHistoryDescription: "Delete chat history from this device.",
clearAction: "Clear",
clearAllChats: "Clear all chats",
clearAllChatsDescription: "Permanently delete every chat on this device.",
@ -228,7 +234,7 @@ export const en = {
clearOneChatTitle: "Clear 1 chat?",
clearChatsTitle: "Clear {count} chats?",
clearChatsConfirmDescription:
"This permanently deletes every chat and message stored on this device. This cannot be undone.",
"Permanently deletes every chat on this device. This cannot be undone.",
clearingAction: "Clearing...",
clearOneChatAction: "Clear 1 chat",
clearChatCountAction: "Clear {count} chats",
@ -251,12 +257,12 @@ export const en = {
},
connections: {
title: "Connections",
description: "Manage providers and external service connections.",
description: "Manage providers and external connections.",
},
apiKeys: {
title: "API",
description:
"Access Unsloth programmatically via the OpenAI-compatible API.",
"Access Unsloth via the OpenAI-compatible API.",
readDocs: "Read the API docs",
noAccess: "No API access yet.",
newBadge: "New",
@ -296,62 +302,71 @@ export const en = {
revokeToken: "Revoke token",
revokeTitle: 'Revoke access token "{name}"?',
revokeDescription:
"Applications using this token will immediately lose access. This cannot be undone.",
"Apps using this token immediately lose access. This cannot be undone.",
revokeAction: 'Revoke "{name}"',
revoking: "Revoking...",
},
about: {
title: "About",
description:
"Documentation, release notes, feedback, and Studio build info.",
studioVersion: "Studio Version",
"Docs, release notes, feedback, and build info.",
studioVersion: "Unsloth Version",
packageVersion: "Package Version",
updates: "Updates",
updates: "Update",
help: "Help",
documentation: "Documentation",
releaseNotes: "Release notes",
whatsNew: "What's new",
feedback: "Feedback",
reportIssue: "Report an issue",
license: {
sectionTitle: "License",
studioLabel: "Unsloth Studio",
studioLicense: "AGPL-3.0",
studioDescription:
"Open source under the GNU AGPL v3.0.",
libraryLabel: "Unsloth Core",
libraryLicense: "Apache-2.0",
libraryDescription: "Licensed under Apache 2.0.",
},
dangerZone: "Danger zone",
shutDownStudio: "Shut down Unsloth Studio",
shutDownStudioDescription:
"Stops the Studio server process and ends your session.",
"Stops the Unsloth server and ends your session.",
shutDown: "Shut down",
update: {
title: "Update Unsloth Studio",
openPowerShell: "Open PowerShell and run:",
openTerminal: "Open Terminal and run:",
commandText: "{label} text",
copied: "Copied",
copyCommand: "Copy command",
commandCopied: "{label} copied",
copyNamedCommand: "Copy {label}",
checkingInstall: "Checking how Studio was installed...",
checkingInstall: "Checking how Unsloth was installed...",
installIntro: "To install or update Unsloth:",
localUpdateHeading: "Local update",
installCommandUnix: "macOS/Linux install command",
installCommandWindows: "Windows install command",
localInstallDetected:
"Source or local install detected. To avoid replacing it with PyPI, update from the checkout or source you originally installed from.",
pullThenUpdate:
"Pull latest changes from your Unsloth repo checkout, then update Studio locally:",
"Local install detected. Update from your original checkout to avoid replacing it with PyPI.",
pullThenUpdate: "Pull the latest changes, then run the local installer:",
gitPullCommand: "git pull command",
localUpdateCommand: "local update command",
localInstallerFallback:
"If the Studio update command is unavailable, run the local installer from that checkout:",
localInstallerCommand: "local installer command",
sourceInstallDetected:
"This looks like a source or VCS package install. Reinstall from the original local path or Git URL you used.",
"Source or VCS package install detected. Reinstall from the original local path or Git URL.",
repoCheckoutFallback:
"If you still have the Unsloth repo checkout, run the local installer from that checkout:",
restartAfterUpdate:
"Restart Studio after updating for changes to take effect.",
"If you still have the repo checkout, run the local installer from it:",
restartAfterUpdate: "Restart Unsloth after updating.",
desktopManaged:
"The desktop app keeps its bundled backend updated and will prompt when a new version is available.",
unknownInstall:
"Studio could not detect how it was installed. Check how you installed Studio first, then choose the matching update path.",
curlOrPypi: "For curl or PyPI installs, run:",
updateCommand: "update command",
"Could not detect how Unsloth was installed. For installer or PyPI installs, use the commands above.",
localCheckout:
"For local checkout installs, update from that checkout instead and use the local update command:",
fallbackInstruction:
"If that fails or unsloth studio update is unavailable, run:",
fallbackCommand: "fallback command",
"For local checkout installs, run the local installer from that checkout:",
docs: "Install docs:",
docsInstall: "Installation",
docsUpdating: "Updating",
docsMac: "Mac",
docsWindows: "Windows",
},
},
},

View file

@ -93,7 +93,7 @@ export const zhCN = {
chat: "聊天",
connections: "连接",
apiKeys: "API",
about: "帮助",
about: "关于",
},
general: {
title: "通用",
@ -106,6 +106,12 @@ export const zhCN = {
chatDefaults: "聊天默认设置",
autoTitleNewChats: "自动为新聊天命名",
autoTitleNewChatsDescription: "根据第一条消息生成简短标题。",
notifications: {
sectionTitle: "通知",
showLlamaUpdates: "llama.cpp 更新通知",
showLlamaUpdatesDescription:
"有新的 llama.cpp 构建时提醒。如果只用于训练可以关闭。",
},
gettingStarted: "入门",
startOnboarding: "开始引导",
startOnboardingDescription: "重新打开设置向导,不会更改你的账号。",
@ -124,13 +130,13 @@ export const zhCN = {
action: "重置偏好设置",
confirmTitle: "重置所有本地偏好设置?",
confirmDescription:
"这会清除仅保存在本地的偏好设置,然后重新加载 Studio。聊天、API 访问权限和数据库中的设置不会受到影响。",
"这会清除仅保存在本地的偏好设置,然后重新加载 Unsloth。聊天、API 访问权限和数据库中的设置不会受到影响。",
confirmAction: "重置并重新加载",
},
},
profile: {
title: "个人资料",
description: "更新你在 Studio 中显示的个人资料。",
description: "更新你在 Unsloth 中显示的个人资料。",
changePicture: "更换头像",
displayName: "显示名称",
nameSaved: "个人资料名称已保存",
@ -150,7 +156,7 @@ export const zhCN = {
language: {
title: "语言",
label: "显示语言",
description: "选择 Studio 使用的语言。",
description: "选择 Unsloth 使用的语言。",
},
theme: {
title: "主题",
@ -274,9 +280,9 @@ export const zhCN = {
revoking: "撤销中...",
},
about: {
title: "帮助",
description: "文档、发布说明、反馈和 Studio 构建信息。",
studioVersion: "Studio 版本",
title: "关于",
description: "文档、发布说明、反馈和 Unsloth 构建信息。",
studioVersion: "Unsloth 版本",
packageVersion: "包版本",
updates: "更新",
help: "帮助",
@ -285,43 +291,52 @@ export const zhCN = {
whatsNew: "最新内容",
feedback: "反馈",
reportIssue: "报告问题",
license: {
sectionTitle: "许可证",
studioLabel: "Unsloth Studio",
studioLicense: "AGPL-3.0",
studioDescription: "基于 GNU AGPL v3.0 开源。",
libraryLabel: "Unsloth Core",
libraryLicense: "Apache-2.0",
libraryDescription: "基于 Apache License 2.0 许可。",
},
dangerZone: "危险区域",
shutDownStudio: "关闭 Unsloth Studio",
shutDownStudioDescription: "停止 Studio 服务进程并结束你的会话。",
shutDownStudioDescription: "停止 Unsloth 服务进程并结束你的会话。",
shutDown: "关闭",
update: {
title: "更新 Unsloth Studio",
openPowerShell: "打开 PowerShell 并运行:",
openTerminal: "打开终端并运行:",
commandText: "{label} 文本",
copied: "已复制",
copyCommand: "复制命令",
commandCopied: "{label} 已复制",
copyNamedCommand: "复制 {label}",
checkingInstall: "正在检查 Studio 的安装方式...",
checkingInstall: "正在检查 Unsloth 的安装方式...",
installIntro: "安装或更新 Unsloth",
localUpdateHeading: "本地更新",
installCommandUnix: "macOS/Linux 安装命令",
installCommandWindows: "Windows 安装命令",
localInstallDetected:
"检测到源码或本地安装。为避免替换为 PyPI 版本,请从最初安装时使用的 checkout 或源码位置更新。",
pullThenUpdate:
"从你的 Unsloth 仓库 checkout 拉取最新变更,然后本地更新 Studio",
"检测到本地安装。请从最初的 checkout 更新,以免被 PyPI 版本替换。",
pullThenUpdate: "拉取最新变更,然后运行本地安装器:",
gitPullCommand: "git pull 命令",
localUpdateCommand: "本地更新命令",
localInstallerFallback:
"如果 Studio 更新命令不可用,请从该 checkout 运行本地安装器:",
localInstallerCommand: "本地安装器命令",
sourceInstallDetected:
"这看起来是源码或 VCS 包安装。请从最初使用的本地路径或 Git URL 重新安装。",
repoCheckoutFallback:
"如果你仍保留 Unsloth 仓库 checkout请从该 checkout 运行本地安装器:",
restartAfterUpdate: "更新后重启 Studio使变更生效。",
restartAfterUpdate: "更新后请重启 Unsloth。",
desktopManaged:
"桌面应用会自动更新其内置后端,有新版本时会提示。",
unknownInstall:
"Studio 无法检测安装方式。请先确认你如何安装 Studio然后选择匹配的更新方式。",
curlOrPypi: "对于 curl 或 PyPI 安装,请运行:",
updateCommand: "更新命令",
"Unsloth 无法检测安装方式。如果你使用一键安装器或 PyPI 安装,请使用上面的命令。",
localCheckout:
"对于本地 checkout 安装,请改为从该 checkout 更新并使用本地更新命令:",
fallbackInstruction:
"如果失败,或 unsloth studio update 不可用,请运行:",
fallbackCommand: "备用命令",
"对于本地 checkout 安装,请改为从该 checkout 运行本地安装器:",
docs: "安装文档:",
docsInstall: "安装",
docsUpdating: "更新",
docsMac: "Mac",
docsWindows: "Windows",
},
},
},

View file

@ -552,6 +552,7 @@
.sidebar-nav-btn[data-state="open"],
.group\/project-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.group\/project-chat-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.group\/projects-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.group\/recent-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.group\/run-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn {
background-color: var(--nav-surface-hover) !important;
@ -562,6 +563,7 @@
.dark .sidebar-nav-btn[data-state="open"],
.dark .group\/project-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.dark .group\/project-chat-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.dark .group\/projects-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.dark .group\/recent-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.dark .group\/run-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn {
color: #fff !important;
@ -957,6 +959,11 @@
border-radius: 14px !important;
}
/* Account menu: a touch rounder than standard list menus. */
[data-slot="dropdown-menu-content"].app-user-menu.menu-soft-surface-up {
border-radius: 18px !important;
}
/* Every dropdown/menu/select/popover: borderless, chatbox shadow in light,
none in dark. !important so it also overrides the bespoke menu shadows. */
[data-slot="dropdown-menu-content"],
@ -1078,13 +1085,16 @@
[data-pill-compact="true"]
.composer-pill-btn:not([data-keep-label])[data-pill-label]:hover::after {
content: attr(data-pill-label);
@apply pointer-events-none absolute bottom-[calc(100%+6px)] left-1/2 z-50 -translate-x-1/2 rounded-[9px] bg-black px-2.5 py-1.5 text-[11px] font-medium leading-snug whitespace-nowrap text-white shadow-md;
/* Always one nowrap line, so always a full pill. */
@apply pointer-events-none absolute bottom-[calc(100%+6px)] left-1/2 z-50 -translate-x-1/2 rounded-full bg-black px-2.5 py-1.5 text-[11px] font-medium leading-snug whitespace-nowrap text-white shadow-md;
}
/* Compact caret pills (RAG, MCP) open their menu on click instead of
toggling off, so keep the icon and skip the X swap. */
toggling off, so keep the icon and skip the X swap. No data-active
requirement: the off-switch glyph rules below hide the icon on hover
even for inactive pills, which left a blank slot in compact mode. */
[data-pill-compact="true"]
.composer-pill-btn:not([data-keep-label]):has(.composer-pill-caret)[data-active="true"]:hover
.composer-pill-btn:not([data-keep-label]):has(.composer-pill-caret):hover
.composer-pill-glyph
> :not(.composer-pill-x) {
opacity: 1;
@ -1512,6 +1522,20 @@
}
}
/* Indeterminate loading bar: a 1/3-width segment sweeping the track. */
.loading-bar-slide {
animation: loading-bar-slide 1.3s ease-in-out infinite;
}
@keyframes loading-bar-slide {
from {
transform: translateX(-110%);
}
to {
transform: translateX(420%);
}
}
@keyframes artifact-loading-line {
0% {
transform: translate3d(-125%, 0, 0) scaleX(0.78);
@ -1750,10 +1774,10 @@
margin: 0 !important;
}
/* Composer shadow on the dark background color so toasts
do not merge into card-colored surfaces behind them. */
/* Dark toasts share the chatbox surface color (.chat-composer-surface
uses var(--card) in dark); the composer shadow lifts them off the page. */
.dark [data-sonner-toast][data-styled='true'] {
background-color: var(--background) !important;
background-color: var(--card) !important;
box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.16) !important;
}
@ -2099,6 +2123,11 @@
animation-iteration-count: infinite !important;
}
.loading-bar-slide {
animation-duration: 1.3s !important;
animation-iteration-count: infinite !important;
}
/* Keep the plus/x morph animating under reduced motion (small rotation,
like the spinners above). */
.unsloth-composer-plus svg {

View file

@ -460,8 +460,14 @@ def is_busy_lock_error(exc: BaseException) -> bool:
return False
# Status logs default to stderr so resolver modes keep stdout machine-readable
# (setup.sh json.load()s the whole stdout). main() flips this for the install
# path, where PowerShell otherwise renders stderr as NativeCommandError noise.
_LOG_TO_STDOUT = False
def log(message: str) -> None:
print(f"[llama-prebuilt] {message}", file = sys.stderr)
print(f"[llama-prebuilt] {message}", file = sys.stdout if _LOG_TO_STDOUT else sys.stderr)
def log_lines(lines: Iterable[str]) -> None:
@ -856,6 +862,16 @@ def format_byte_count(num_bytes: float) -> str:
return f"{num_bytes:.1f} B"
def _progress_percent_step() -> int:
"""Non-tty milestone granularity. The in-app updater sets
UNSLOTH_PROGRESS_PERCENT_STEP=5 to stream finer progress lines."""
try:
step = int(os.environ.get("UNSLOTH_PROGRESS_PERCENT_STEP", "25"))
except ValueError:
return 25
return min(max(step, 1), 50)
class DownloadProgress:
def __init__(self, label: str, total_bytes: int | None) -> None:
self.label = label
@ -868,6 +884,7 @@ class DownloadProgress:
)
self.is_tty = term_ok and self.stream.isatty()
self.completed = False
self.milestone_step = _progress_percent_step()
self.last_milestone_percent = -1
self.last_milestone_bytes = 0
self.has_rendered_tty_progress = False
@ -918,7 +935,8 @@ class DownloadProgress:
should_emit = False
if self.total_bytes is not None:
percent = int((downloaded_bytes * 100) / max(self.total_bytes, 1))
milestone_percent = min((percent // 25) * 25, 100)
step = self.milestone_step
milestone_percent = min((percent // step) * step, 100)
if milestone_percent > self.last_milestone_percent and milestone_percent < 100:
self.last_milestone_percent = milestone_percent
should_emit = True
@ -3916,6 +3934,12 @@ def _is_trusted_github_release_url(url: str, expected_repo: str) -> bool:
return False
# (gfx_target, asset_name) pairs already logged. resolve_lemonade_rocm_choice()
# runs twice per install (direct planner + resolve_upstream_asset_choice), so
# this stops its selection banner and hash-manifest NOTE printing twice.
_lemonade_selection_logged: "set[tuple[str, str]]" = set()
@functools.lru_cache(maxsize = 8)
def _fetch_lemonade_release_cached(api_url: str, llama_tag: str) -> "dict | None":
"""Cached wrapper around fetch_json for lemonade release lookups.
@ -4022,18 +4046,22 @@ def resolve_lemonade_rocm_choice(
# Note: lemonade tags Linux assets with "ubuntu" but the binary is a
# generic glibc build that runs on any distro (Arch, Fedora, ...), so
# this attempt is selected for all Linux ROCm hosts, not just Ubuntu.
log(
f"AMD GPU {host.rocm_gfx_target!r} ({gfx_family}) -- "
f"trying lemonade-sdk ROCm prebuilt {asset_name} "
f"(works on any glibc Linux, not just Ubuntu)"
)
log(
f"NOTE: lemonade-sdk/llamacpp-rocm releases are not covered by the "
f"Unsloth approved-hash manifest; download integrity relies on "
f"functional validation (llama-bench / llama-server smoke tests) "
f"after extraction. Set UNSLOTH_DISABLE_LEMONADE_ROCM=1 to skip "
f"lemonade and fall back to the upstream HIP build path."
)
# Log once per (gfx_target, asset); see _lemonade_selection_logged.
log_key = (host.rocm_gfx_target, asset_name)
if log_key not in _lemonade_selection_logged:
_lemonade_selection_logged.add(log_key)
log(
f"AMD GPU {host.rocm_gfx_target!r} ({gfx_family}) -- "
f"trying lemonade-sdk ROCm prebuilt {asset_name} "
f"(works on any glibc Linux, not just Ubuntu)"
)
log(
f"NOTE: lemonade-sdk/llamacpp-rocm releases are not covered by the "
f"Unsloth approved-hash manifest; download integrity relies on "
f"functional validation (llama-bench / llama-server smoke tests) "
f"after extraction. Set UNSLOTH_DISABLE_LEMONADE_ROCM=1 to skip "
f"lemonade and fall back to the upstream HIP build path."
)
return AssetChoice(
repo = LEMONADE_ROCM_REPO,
tag = release_tag,
@ -7012,6 +7040,9 @@ def main() -> int:
raise SystemExit(
"install_llama_prebuilt.py: --install-dir is required unless --resolve-llama-tag, --resolve-install-tag, or --resolve-source-build is used"
)
# Install path only: route status logs to stdout (see _LOG_TO_STDOUT note).
global _LOG_TO_STDOUT
_LOG_TO_STDOUT = True
install_prebuilt(
install_dir = Path(args.install_dir).expanduser().resolve(),
llama_tag = args.llama_tag,

View file

@ -300,7 +300,14 @@ def test_object_style_rope_scaling_on_config_delegates_correctly():
expected = _reference_inv_freq(dict_config, "linear")
object_config = _make_config({"rope_type": "linear", "factor": 4.0})
object_config.rope_scaling = FakeLinearRopeScalingConfig()
try:
object_config.rope_scaling = FakeLinearRopeScalingConfig()
except Exception:
pytest.skip(
"transformers strict-validates rope_scaling to dict/RopeParameters/None, "
"so object-style config.rope_scaling (and the delegation retry it "
"exercises) is unreachable on this version."
)
inv_freq, attention_scaling = _compute_config_rope_inv_freq(
object_config, object_config.rope_scaling
)

View file

@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "2026.6.2"
__version__ = "2026.6.3"
__all__ = [
"SUPPORTS_BFLOAT16",

View file

@ -1673,12 +1673,26 @@ def _llama3_inv_freq_from_config(
return torch.where(is_medium, smoothed, scaled)
def _vanilla_inv_freq_from_config(config, device = "cpu"):
"""Unscaled RoPE inv_freq (rope_type 'default'/None), matching the constructor's fallback."""
base = _get_rope_theta(config, default = 10000.0)
dim = getattr(config, "head_dim", None)
if dim is None:
dim = int(config.hidden_size // config.num_attention_heads)
return 1.0 / (base ** (torch.arange(0, dim, 2, dtype = torch.int64, device = device).float() / dim))
def _compute_config_rope_inv_freq(config, rope_scaling):
"""(inv_freq, attention_scaling) per config.rope_scaling via transformers'
ROPE_INIT_FUNCTIONS, with an inline llama3 fallback; (None, 1.0) on failure."""
original_rope_scaling = rope_scaling
rope_scaling = _rope_scaling_as_dict(rope_scaling)
rope_type = rope_scaling.get("rope_type", None) or rope_scaling.get("type", None)
# "default"/unset means unscaled RoPE. transformers >=5 reports
# rope_type="default" for every plain config and dropped "default" from
# ROPE_INIT_FUNCTIONS, so compute it directly instead of warning per load.
if rope_type in (None, "default"):
return _vanilla_inv_freq_from_config(config).to(dtype = torch.float32, device = "cpu"), 1.0
try:
from transformers.modeling_rope_utils import ROPE_INIT_FUNCTIONS

View file

@ -570,6 +570,19 @@ def _load_model_via_http(
raise RuntimeError(f"Model load failed (HTTP {exc.code}): {body}") from exc
def _format_context_length_line(load_result: dict) -> Optional[str]:
value = load_result.get("context_length")
if isinstance(value, bool):
return None
try:
value_int = int(value)
except (TypeError, ValueError):
return None
if value_int <= 0:
return None
return f" Context length: {value_int} tokens"
# ── unsloth studio (server) ──────────────────────────────────────────
@ -841,7 +854,10 @@ def run(
None, "--gguf-variant", help = "GGUF quant variant (e.g. UD-Q4_K_XL)"
),
max_seq_length: int = typer.Option(
0, "--max-seq-length", help = "Max sequence length (0 = model default)"
0,
"--max-seq-length",
"--context-length",
help = "Runtime context length in tokens (0 = model default for GGUF; 2048 for hub models)",
),
load_in_4bit: bool = typer.Option(True, "--load-in-4bit/--no-load-in-4bit"),
api_key_name: str = typer.Option(
@ -1080,6 +1096,7 @@ def run(
loaded_model = result.get("model", model)
display_variant = f" ({gguf_variant})" if gguf_variant else ""
context_length_line = _format_context_length_line(result)
# 6. Print banner.
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
@ -1119,6 +1136,8 @@ def run(
if _cf_url:
typer.echo(f" Secure link access via Cloudflare: {_cf_url}")
typer.echo(f" Model loaded: {loaded_model}{display_variant}")
if context_length_line:
typer.echo(context_length_line)
typer.echo(f" API Key: {api_key}")
typer.echo("")
typer.echo(" OpenAI / Anthropic SDK base URL:")
@ -1153,6 +1172,8 @@ def run(
typer.echo(f"URL: {base_url}")
if _cf_url:
typer.echo(f"Secure link access via Cloudflare: {_cf_url}")
if context_length_line:
typer.echo(context_length_line.strip())
typer.echo(f"API Key: {api_key}")
typer.secho(_tool_notice, fg = _tool_notice_fg, bold = True)

View file

@ -51,6 +51,18 @@ def test_parallel_option_is_registered():
assert required in flags, f"flag {required!r} missing from --parallel option"
def test_context_length_alias_is_registered():
"""`--context-length` is an operator-facing alias for --max-seq-length."""
studio_mod = _load_run_command()
import inspect
sig = inspect.signature(studio_mod.run)
opt = sig.parameters["max_seq_length"].default
flags = set(getattr(opt, "param_decls", None) or [])
assert "--max-seq-length" in flags
assert "--context-length" in flags
def test_parallel_default_is_four():
"""Default must stay at 4 so plain `unsloth studio run` is unchanged."""
studio_mod = _load_run_command()
@ -249,6 +261,15 @@ def test_reexec_np_is_first_class_alias(monkeypatch):
assert _value_after(argv, "--port") == "8888", argv
def test_reexec_forwards_context_length_alias(monkeypatch):
"""Alias should normalize to the existing child --max-seq-length flag."""
result, captured = _invoke_run(monkeypatch, _BASE + ["--context-length", "8192"])
assert len(captured) == 1, result.output
argv = captured[0]["argv"]
assert _value_after(argv, "--max-seq-length") == "8192", argv
assert "--context-length" not in argv, argv
def test_reexec_mixed_parallel_with_passthrough(monkeypatch):
"""--parallel + llama-server pass-through flags must all reach the child."""
result, captured = _invoke_run(
@ -262,6 +283,22 @@ def test_reexec_mixed_parallel_with_passthrough(monkeypatch):
assert _value_after(argv, "--temp") == "0.7", argv
def test_context_length_banner_line_formats_ints():
studio_mod = _load_run_command()
assert studio_mod._format_context_length_line({"context_length": 4096}) == (
" Context length: 4096 tokens"
)
assert studio_mod._format_context_length_line({"context_length": "8192"}) == (
" Context length: 8192 tokens"
)
@pytest.mark.parametrize("value", [None, 0, -1, True, ""])
def test_context_length_banner_line_omits_unknown_values(value):
studio_mod = _load_run_command()
assert studio_mod._format_context_length_line({"context_length": value}) is None
@pytest.mark.parametrize(
"user_flag,expected_in_child",
[