diff --git a/install.ps1 b/install.ps1
index fd7d3f4a81..cf7bb63cdf 100644
--- a/install.ps1
+++ b/install.ps1
@@ -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)
diff --git a/install.sh b/install.sh
index df9eca65c7..532ac61bc0 100755
--- a/install.sh
+++ b/install.sh
@@ -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..."
diff --git a/pyproject.toml b/pyproject.toml
index 9f6465d3a3..16fd9f833f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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",
diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py
index 55c2c551a3..2b9517692f 100644
--- a/studio/backend/core/inference/inference.py
+++ b/studio/backend/core/inference/inference.py
@@ -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)
diff --git a/studio/backend/core/inference/mlx_inference.py b/studio/backend/core/inference/mlx_inference.py
index fbbdb9a773..5c7799152f 100644
--- a/studio/backend/core/inference/mlx_inference.py
+++ b/studio/backend/core/inference/mlx_inference.py
@@ -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.
diff --git a/studio/backend/core/inference/orchestrator.py b/studio/backend/core/inference/orchestrator.py
index 32bb25c976..4e4788ace9 100644
--- a/studio/backend/core/inference/orchestrator.py
+++ b/studio/backend/core/inference/orchestrator.py
@@ -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.
diff --git a/studio/backend/core/inference/runtime_context.py b/studio/backend/core/inference/runtime_context.py
new file mode 100644
index 0000000000..8b881628be
--- /dev/null
+++ b/studio/backend/core/inference/runtime_context.py
@@ -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
diff --git a/studio/backend/core/inference/worker.py b/studio/backend/core/inference/worker.py
index 4f5b985234..cc79654087 100644
--- a/studio/backend/core/inference/worker.py
+++ b/studio/backend/core/inference/worker.py
@@ -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()
},
diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py
index 217a2e7fcf..24071f400b 100644
--- a/studio/backend/models/inference.py
+++ b/studio/backend/models/inference.py
@@ -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"
diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py
index ac169a323d..16d877acba 100644
--- a/studio/backend/routes/inference.py
+++ b/studio/backend/routes/inference.py
@@ -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
diff --git a/studio/backend/routes/llama.py b/studio/backend/routes/llama.py
index b30c0f5aea..3aae6f4209 100644
--- a/studio/backend/routes/llama.py
+++ b/studio/backend/routes/llama.py
@@ -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."
)
diff --git a/studio/backend/run.py b/studio/backend/run.py
index 32dfe06a18..a883154c4a 100644
--- a/studio/backend/run.py
+++ b/studio/backend/run.py
@@ -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"
diff --git a/studio/backend/tests/test_cloudflare_tunnel.py b/studio/backend/tests/test_cloudflare_tunnel.py
index 8208f7f83f..873547631d 100644
--- a/studio/backend/tests/test_cloudflare_tunnel.py
+++ b/studio/backend/tests/test_cloudflare_tunnel.py
@@ -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, "", "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 == ""
diff --git a/studio/backend/tests/test_lemonade_llamacpp_rocm_bins_mock.py b/studio/backend/tests/test_lemonade_llamacpp_rocm_bins_mock.py
index 57bbc84fb0..7c6c514b8f 100644
--- a/studio/backend/tests/test_lemonade_llamacpp_rocm_bins_mock.py
+++ b/studio/backend/tests/test_lemonade_llamacpp_rocm_bins_mock.py
@@ -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"
diff --git a/studio/backend/tests/test_llama_cpp_freshness.py b/studio/backend/tests/test_llama_cpp_freshness.py
index cb17e0d5e7..f8e4619ded 100644
--- a/studio/backend/tests/test_llama_cpp_freshness.py
+++ b/studio/backend/tests/test_llama_cpp_freshness.py
@@ -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"
diff --git a/studio/backend/tests/test_llama_cpp_update.py b/studio/backend/tests/test_llama_cpp_update.py
index 05a107377c..3b8f511a7c 100644
--- a/studio/backend/tests/test_llama_cpp_update.py
+++ b/studio/backend/tests/test_llama_cpp_update.py
@@ -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-`` 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"
diff --git a/studio/backend/tests/test_native_context_length.py b/studio/backend/tests/test_native_context_length.py
index 0290bb1308..de1ca0649e 100644
--- a/studio/backend/tests/test_native_context_length.py
+++ b/studio/backend/tests/test_native_context_length.py
@@ -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
diff --git a/studio/backend/utils/llama_cpp_freshness.py b/studio/backend/utils/llama_cpp_freshness.py
index 3e5066ca2d..f5fd745334 100644
--- a/studio/backend/utils/llama_cpp_freshness.py
+++ b/studio/backend/utils/llama_cpp_freshness.py
@@ -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-`` (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-). 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"])
diff --git a/studio/backend/utils/llama_cpp_update.py b/studio/backend/utils/llama_cpp_update.py
index b138b29af3..654ade6cd4 100644
--- a/studio/backend/utils/llama_cpp_update.py
+++ b/studio/backend/utils/llama_cpp_update.py
@@ -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,
)
diff --git a/studio/backend/utils/studio_version.py b/studio/backend/utils/studio_version.py
index 98c48fe45c..9c18070fbb 100644
--- a/studio/backend/utils/studio_version.py
+++ b/studio/backend/utils/studio_version.py
@@ -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):
diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx
index 7baa69571c..f1a200b09b 100644
--- a/studio/frontend/src/components/app-sidebar.tsx
+++ b/studio/frontend/src/components/app-sidebar.tsx
@@ -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"
+ >
+
+
-
+
{displayTitle}Unsloth
diff --git a/studio/frontend/src/components/llama-update-banner.tsx b/studio/frontend/src/components/llama-update-banner.tsx
index c8a4dd1e40..83320f8e15 100644
--- a/studio/frontend/src/components/llama-update-banner.tsx
+++ b/studio/frontend/src/components/llama-update-banner.tsx
@@ -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(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 (
{show ? (
-
+
{applying ? null : (
) : null}
diff --git a/studio/frontend/src/components/tauri/startup-screen.tsx b/studio/frontend/src/components/tauri/startup-screen.tsx
index 1eb3f81d10..8e373c82a0 100644
--- a/studio/frontend/src/components/tauri/startup-screen.tsx
+++ b/studio/frontend/src/components/tauri/startup-screen.tsx
@@ -228,7 +228,7 @@ function RepairingContent({
-
Updating existing Studio install...
+
Updating existing Unsloth install...
{latest && (
{latest}
)}
diff --git a/studio/frontend/src/components/ui/tooltip.tsx b/studio/frontend/src/components/ui/tooltip.tsx
index 954b1871f3..cbc1a09a8f 100644
--- a/studio/frontend/src/components/ui/tooltip.tsx
+++ b/studio/frontend/src/components/ui/tooltip.tsx
@@ -98,9 +98,27 @@ function TooltipContent({
}: React.ComponentProps & {
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 (
s.deviceType);
+ const installCmd =
+ deviceType === "windows"
+ ? STUDIO_INSTALL_WINDOWS_CMD
+ : STUDIO_INSTALL_UNIX_CMD;
const [copiedVersion, setCopiedVersion] = useState(null);
const dismissTimerRef = useRef | 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}
- 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.
diff --git a/studio/frontend/src/config/env.ts b/studio/frontend/src/config/env.ts
index 92dad2ae27..61ab111d45 100644
--- a/studio/frontend/src/config/env.ts
+++ b/studio/frontend/src/config/env.ts
@@ -46,12 +46,28 @@ export async function fetchDeviceType(): Promise {
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 {
diff --git a/studio/frontend/src/features/auth/api.ts b/studio/frontend/src/features/auth/api.ts
index 6356f89378..144e85d40a 100644
--- a/studio/frontend/src/features/auth/api.ts
+++ b/studio/frontend/src/features/auth/api.ts
@@ -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;
}
diff --git a/studio/frontend/src/features/auth/tauri-auto-auth.ts b/studio/frontend/src/features/auth/tauri-auto-auth.ts
index 760af26395..e825d5de31 100644
--- a/studio/frontend/src/features/auth/tauri-auto-auth.ts
+++ b/studio/frontend/src/features/auth/tauri-auto-auth.ts
@@ -26,7 +26,7 @@ let pending: { promise: Promise; 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 {
diff --git a/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts b/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts
index a679e8ed83..146a12bd40 100644
--- a/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts
+++ b/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts
@@ -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.";
diff --git a/studio/frontend/src/features/chat/chat-providers-dialog.tsx b/studio/frontend/src/features/chat/chat-providers-dialog.tsx
index e35a8e35e0..0594b55083 100644
--- a/studio/frontend/src/features/chat/chat-providers-dialog.tsx
+++ b/studio/frontend/src/features/chat/chat-providers-dialog.tsx
@@ -1511,7 +1511,7 @@ export function ChatProvidersSettings({
Connections
- Manage model connections for chat through the Studio proxy.
+ Manage model connections for chat.
diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx
index 03c183fe76..5751a1a426 100644
--- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx
+++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx
@@ -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({
diff --git a/studio/frontend/src/features/chat/tour/steps.tsx b/studio/frontend/src/features/chat/tour/steps.tsx
index de27e93c88..cbbb7bae4f 100644
--- a/studio/frontend/src/features/chat/tour/steps.tsx
+++ b/studio/frontend/src/features/chat/tour/steps.tsx
@@ -28,7 +28,7 @@ export function buildChatTourSteps({
body: (
<>
This selects what’s 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 you’ve
+ Hub: search Hugging Face models. Fine-tuned: local Unsloth outputs you’ve
trained or exported. If results look off, compare base vs fine-tuned
outputs to see what changed.
>
diff --git a/studio/frontend/src/features/chat/utils/chat-history-storage.ts b/studio/frontend/src/features/chat/utils/chat-history-storage.ts
index 0e9b30a5cc..2023ae30b1 100644
--- a/studio/frontend/src/features/chat/utils/chat-history-storage.ts
+++ b/studio/frontend/src/features/chat/utils/chat-history-storage.ts
@@ -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.")
);
}
diff --git a/studio/frontend/src/features/hub/download-manager/api.ts b/studio/frontend/src/features/hub/download-manager/api.ts
index ca987f1643..2c55b90edd 100644
--- a/studio/frontend/src/features/hub/download-manager/api.ts
+++ b/studio/frontend/src/features/hub/download-manager/api.ts
@@ -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 =
diff --git a/studio/frontend/src/features/hub/download-manager/download-api-adapter.ts b/studio/frontend/src/features/hub/download-manager/download-api-adapter.ts
index 6942dfbbb9..a6518ec656 100644
--- a/studio/frontend/src/features/hub/download-manager/download-api-adapter.ts
+++ b/studio/frontend/src/features/hub/download-manager/download-api-adapter.ts
@@ -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", {
diff --git a/studio/frontend/src/features/recipe-studio/components/executions/execution-data-tab.tsx b/studio/frontend/src/features/recipe-studio/components/executions/execution-data-tab.tsx
index a3e5dbe962..1616f6f77d 100644
--- a/studio/frontend/src/features/recipe-studio/components/executions/execution-data-tab.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/executions/execution-data-tab.tsx
@@ -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.";
}
diff --git a/studio/frontend/src/features/recipe-studio/components/executions/execution-overview-tab.tsx b/studio/frontend/src/features/recipe-studio/components/executions/execution-overview-tab.tsx
index 72624738fc..5ff9d644e7 100644
--- a/studio/frontend/src/features/recipe-studio/components/executions/execution-overview-tab.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/executions/execution-overview-tab.tsx
@@ -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.";
}
diff --git a/studio/frontend/src/features/recipe-studio/components/runtime/execution-progress-island.tsx b/studio/frontend/src/features/recipe-studio/components/runtime/execution-progress-island.tsx
index 563280c55f..1a194ba857 100644
--- a/studio/frontend/src/features/recipe-studio/components/runtime/execution-progress-island.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/runtime/execution-progress-island.tsx
@@ -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.";
diff --git a/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx
index 878f9eba7c..0ed7eeed75 100644
--- a/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx
+++ b/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx
@@ -334,8 +334,8 @@ export function GithubRepoSeedForm({
/>
{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."}
{hasToken && (
@@ -450,7 +450,7 @@ export function GithubRepoSeedForm({
- Backed by Studio's built-in github_repo seed reader. Large
+ Backed by Unsloth's built-in github_repo seed reader. Large
repos can take minutes, so start with small limits for previews.