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 : ( -
+ {updateProgress != null && updateProgress > 0 ? ( +
+ ) : ( + // No percent yet (resolving the release): sweep until the + // first download progress arrives. +
+ )} +
+ ) : ( +
+ + +
+ )}
) : 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({
+