From 3b446b6d24e9b89cb181204253b0326c198afd97 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 10:36:43 +0000 Subject: [PATCH 001/219] Studio: require confirmation and report the target machine for llama.cpp updates A remote authenticated browser could swap the host llama.cpp binary with no confirmation and no visible result. POST /api/llama/update/confirm now mints a single-use, build-bound token, and POST /api/llama/update refuses a bodyless or unconfirmed call instead of running the swap. Update responses include the host name and platform so the result is attributable. Confirmation is required uniformly, so a headless remote server is not gated out; the desktop app, local browser, and CLI update path are unchanged. --- studio/backend/routes/llama.py | 193 +++++++++- studio/backend/tests/test_llama_route.py | 5 +- studio/backend/tests/test_update_contract.py | 350 +++++++++++++++++++ studio/backend/utils/update_confirm.py | 104 ++++++ 4 files changed, 648 insertions(+), 4 deletions(-) create mode 100644 studio/backend/tests/test_update_contract.py create mode 100644 studio/backend/utils/update_confirm.py diff --git a/studio/backend/routes/llama.py b/studio/backend/routes/llama.py index 540647e3bc..6d4b2415dc 100644 --- a/studio/backend/routes/llama.py +++ b/studio/backend/routes/llama.py @@ -14,19 +14,63 @@ never blocks on a missing marker / offline GitHub. from __future__ import annotations import asyncio +import socket +import sys import threading from typing import Optional -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Body, Depends, Query from pydantic import BaseModel, Field from auth.authentication import get_current_subject from loggers import get_logger from utils.llama_cpp_update import get_update_status, start_update +from utils.update_confirm import ( + CONFIRM_TOKEN_TTL_SECONDS, + consume_confirm_token, + mint_confirm_token, +) logger = get_logger(__name__) router = APIRouter() +# Messages for a refused apply, keyed by outcome. A refusal never runs the swap. +_REFUSAL_MESSAGES = { + "confirmation_required": ( + "Confirm the llama.cpp update before it runs. It will download and swap " + "the binary on the machine running Studio." + ), + "invalid_token": ( + "The confirmation is missing or unrecognized. Re-check for the update and " + "confirm again before it runs." + ), + "expired_token": ( + "The confirmation expired. Re-check for the update and confirm again." + ), + "stale_target": ( + "The available build changed since you confirmed. Re-check for the update " + "and confirm the new build before it runs." + ), +} + + +class UpdateMachine(BaseModel): + """Host a swap targets, so a remote operator sees which machine would change.""" + + hostname: str = Field("", description = "Hostname of the machine running Studio.") + platform: str = Field( + "", description = "Host OS tag (sys.platform), e.g. 'linux', 'darwin', 'win32'." + ) + + +def _current_machine() -> UpdateMachine: + """Best-effort host identity; cross-OS and never branched on.""" + try: + hostname = socket.gethostname() or "unknown" + except Exception: # pragma: no cover + hostname = "unknown" + return UpdateMachine(hostname = hostname, platform = sys.platform) + class LlamaUpdateJob(BaseModel): state: str = Field("idle", description = "idle | running | success | error") @@ -62,13 +106,59 @@ class LlamaUpdateStatusResponse(BaseModel): update_size_bytes: Optional[int] = Field( None, description = "Download size of the prebuilt Update would fetch, in bytes." ) + machine: UpdateMachine = Field( + default_factory = UpdateMachine, + description = "The host this status describes; the machine an Update would change.", + ) job: LlamaUpdateJob = Field(default_factory = LlamaUpdateJob) +class LlamaUpdateRequest(BaseModel): + """Body for POST /update. Empty body means no confirmation, so the swap is + refused (safe default) and a stale banner or replay cannot swap the binary.""" + + confirm_token: Optional[str] = Field( + None, + description = "Single-use token from POST /update/confirm, bound to the exact offered build.", + ) + confirmed: bool = Field( + False, + description = "Explicit confirmation for non-interactive/CLI callers that do not use a token.", + ) + + +class LlamaUpdateConfirmResponse(BaseModel): + """Pending swap details plus, when appliable, a confirm token for the prompt.""" + + update_available: bool = False + appliable: bool = Field( + False, description = "True when an update exists and can actually be applied here." + ) + reason: Optional[str] = Field( + None, description = "Why not appliable: up_to_date | local_link | ..." + ) + machine: UpdateMachine = Field(default_factory = UpdateMachine) + installed_tag: Optional[str] = None + latest_tag: Optional[str] = None + update_size_bytes: Optional[int] = None + confirm_token: Optional[str] = Field( + None, description = "Echo back to POST /update to run the swap. Absent when not appliable." + ) + confirm_expires_at: Optional[str] = None + confirm_ttl_seconds: Optional[int] = None + + class LlamaUpdateActionResponse(BaseModel): started: bool reason: Optional[str] = None message: Optional[str] = None + machine: UpdateMachine = Field( + default_factory = UpdateMachine, + description = "The host the swap ran (or would run) on; names the result's machine.", + ) + installed_tag: Optional[str] = None + latest_tag: Optional[str] = None + update_size_bytes: Optional[int] = None job: LlamaUpdateJob = Field(default_factory = LlamaUpdateJob) @@ -102,14 +192,111 @@ async def llama_update_status( ) -> LlamaUpdateStatusResponse: # Off the event loop: detection may probe the host and read GitHub. status = await asyncio.to_thread(get_update_status, force_refresh = force_refresh) - resp = LlamaUpdateStatusResponse(**status) + resp = LlamaUpdateStatusResponse(machine = _current_machine(), **status) _log_llama_update_progress(resp.job) return resp +@router.post("/update/confirm", response_model = LlamaUpdateConfirmResponse) +async def llama_update_confirm( + current_subject: str = Depends(get_current_subject), +) -> LlamaUpdateConfirmResponse: + """Step one of the two-step apply: describe the pending swap and, when it can + be applied, mint a single-use token bound to the offered build. Available to + any authenticated operator; confirmation, not location, is the gate.""" + # Force-refresh so the token binds the current build, not a stale cached tag. + status = await asyncio.to_thread(get_update_status, force_refresh = True) + machine = _current_machine() + installed_tag = status.get("installed_tag") + latest_tag = status.get("latest_tag") + size = status.get("update_size_bytes") + + if not status.get("update_available"): + return LlamaUpdateConfirmResponse( + update_available = False, + appliable = False, + reason = status.get("reason") or "up_to_date", + machine = machine, + installed_tag = installed_tag, + latest_tag = latest_tag, + update_size_bytes = size, + ) + # Update exists but the tree is a --with-llama-cpp-dir local link: describe, do not apply. + if status.get("local_link"): + return LlamaUpdateConfirmResponse( + update_available = True, + appliable = False, + reason = "local_link", + machine = machine, + installed_tag = installed_tag, + latest_tag = latest_tag, + update_size_bytes = size, + ) + + token, expires_at = mint_confirm_token(latest_tag or "") + return LlamaUpdateConfirmResponse( + update_available = True, + appliable = True, + reason = None, + machine = machine, + installed_tag = installed_tag, + latest_tag = latest_tag, + update_size_bytes = size, + confirm_token = token, + confirm_expires_at = expires_at, + confirm_ttl_seconds = CONFIRM_TOKEN_TTL_SECONDS, + ) + + @router.post("/update", response_model = LlamaUpdateActionResponse) async def llama_update( + request: Optional[LlamaUpdateRequest] = Body(default = None), current_subject: str = Depends(get_current_subject), ) -> LlamaUpdateActionResponse: + """Apply the swap, but only with an explicit, fresh confirmation. + + The installer replaces the host binary, so a caller must either echo the + single-use ``confirm_token`` from POST /update/confirm (preferred: replay-safe, + bound to the build) or send ``confirmed=true`` (non-interactive callers). With + neither, the swap is refused and the binary is left untouched. The gate is the + confirmation, not the caller's location, so a headless SSH server confirms like a local one.""" + req = request or LlamaUpdateRequest() + machine = _current_machine() + # Cached read: names the host and build for the prompt and binds the token. + status = await asyncio.to_thread(get_update_status) + installed_tag = status.get("installed_tag") + target_tag = status.get("latest_tag") + size = status.get("update_size_bytes") + + confirmed = False + refuse_reason = "confirmation_required" + if req.confirm_token: + ok, why = consume_confirm_token(req.confirm_token, target_tag or "") + if ok: + confirmed = True + else: + refuse_reason = why or "invalid_token" + elif req.confirmed: + confirmed = True + + if not confirmed: + # Safe default: no confirmation, no swap; return a visible, actionable refusal. + return LlamaUpdateActionResponse( + started = False, + reason = refuse_reason, + message = _REFUSAL_MESSAGES.get(refuse_reason, _REFUSAL_MESSAGES["confirmation_required"]), + machine = machine, + installed_tag = installed_tag, + latest_tag = target_tag, + update_size_bytes = size, + job = LlamaUpdateJob(**status.get("job", {})), + ) + action = await asyncio.to_thread(start_update) - return LlamaUpdateActionResponse(**action) + return LlamaUpdateActionResponse( + machine = machine, + installed_tag = installed_tag, + latest_tag = target_tag, + update_size_bytes = size, + **action, + ) diff --git a/studio/backend/tests/test_llama_route.py b/studio/backend/tests/test_llama_route.py index 0ecfeee018..ac3ec01797 100644 --- a/studio/backend/tests/test_llama_route.py +++ b/studio/backend/tests/test_llama_route.py @@ -128,6 +128,9 @@ def test_update_handler_runs_off_event_loop(monkeypatch): return {"started": True, "reason": None, "job": {"state": "running"}} monkeypatch.setattr(rl, "start_update", fake_start) - out = asyncio.run(rl.llama_update(current_subject = "t")) + # The swap now needs explicit confirmation; confirm here so the off-loop path runs. + out = asyncio.run( + rl.llama_update(request = rl.LlamaUpdateRequest(confirmed = True), current_subject = "t") + ) assert out.started is True assert seen["thread"] is not threading.main_thread() diff --git a/studio/backend/tests/test_update_contract.py b/studio/backend/tests/test_update_contract.py new file mode 100644 index 0000000000..cf84df143c --- /dev/null +++ b/studio/backend/tests/test_update_contract.py @@ -0,0 +1,350 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Contract tests for the confirmation + visible-result llama.cpp update (#7003). + +Properties covered: + - remote authenticated + confirmed -> update proceeds, result names the host + - remote authenticated, no confirm -> refused with a clear message, NO swap + - local / desktop -> identical behavior (no same-machine axis) + - unauthenticated -> refused (401), NO swap + - stale / expired / replayed token -> refused, NO swap + +routes/llama.py is loaded standalone with stubbed auth / loggers / llama_cpp_update +and the real utils.update_confirm, so no heavy backend deps are needed. Both handler +calls and a real FastAPI TestClient (HTTP + auth gate) are exercised. +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import sys +import types +from pathlib import Path +from typing import Optional + +import pytest + +pytest.importorskip("fastapi") + +_HERE = Path(__file__).resolve().parent +_BACKEND = _HERE.parent + + +def _install_stubs(): + """Register stub packages so routes/llama.py imports cleanly, plus the real + update_confirm module under its production name.""" + # auth.authentication.get_current_subject -> a real FastAPI dependency that + # 401s without a valid bearer, so the HTTP tests can prove the auth gate. + from fastapi import Header, HTTPException + + def get_current_subject(authorization: Optional[str] = Header(default = None)) -> str: + if authorization == "Bearer good": + return "operator" + raise HTTPException(status_code = 401, detail = "unauthorized") + + auth_pkg = types.ModuleType("auth") + auth_pkg.__path__ = [] + auth_mod = types.ModuleType("auth.authentication") + auth_mod.get_current_subject = get_current_subject + + # loggers.get_logger -> no-op structured logger. + loggers_mod = types.ModuleType("loggers") + + class _NopLogger: + def info(self, *a, **k): + pass + + def debug(self, *a, **k): + pass + + def warning(self, *a, **k): + pass + + loggers_mod.get_logger = lambda *_a, **_k: _NopLogger() + + # utils.llama_cpp_update -> monkeypatchable get_update_status / start_update. + utils_pkg = sys.modules.get("utils") + if utils_pkg is None: + utils_pkg = types.ModuleType("utils") + utils_pkg.__path__ = [] + sys.modules["utils"] = utils_pkg + + lcu_mod = types.ModuleType("utils.llama_cpp_update") + + def _default_status(force_refresh: bool = False): + return { + "supported": True, + "update_available": True, + "stale": False, + "installed_tag": "b9860", + "latest_tag": "b9909", + "published_repo": "unslothai/llama.cpp", + "installed_at_utc": None, + "age_days": None, + "source_build": False, + "update_size_bytes": 42_000_000, + "job": {"state": "idle"}, + } + + def _default_start(): + return {"started": True, "reason": None, "job": {"state": "running"}} + + lcu_mod.get_update_status = _default_status + lcu_mod.start_update = _default_start + + # The real confirmation-token module, under its production import name. + uc_spec = importlib.util.spec_from_file_location( + "utils.update_confirm", str(_BACKEND / "utils" / "update_confirm.py") + ) + uc_mod = importlib.util.module_from_spec(uc_spec) + uc_spec.loader.exec_module(uc_mod) + + for name, mod in ( + ("auth", auth_pkg), + ("auth.authentication", auth_mod), + ("loggers", loggers_mod), + ("utils.llama_cpp_update", lcu_mod), + ("utils.update_confirm", uc_mod), + ): + sys.modules[name] = mod + return uc_mod + + +_uc = _install_stubs() + + +def _load_route(): + spec = importlib.util.spec_from_file_location( + "llama_route_under_test", str(_BACKEND / "routes" / "llama.py") + ) + mod = importlib.util.module_from_spec(spec) + sys.modules["llama_route_under_test"] = mod # pydantic forward-ref resolution + spec.loader.exec_module(mod) + return mod + + +rl = _load_route() + + +@pytest.fixture(autouse = True) +def _reset(monkeypatch): + _uc.reset_tokens_for_tests() + # Restore default status/start each test; individual tests override as needed. + monkeypatch.setattr( + rl, + "get_update_status", + lambda force_refresh = False: { + "supported": True, + "update_available": True, + "installed_tag": "b9860", + "latest_tag": "b9909", + "update_size_bytes": 42_000_000, + "job": {"state": "idle"}, + }, + ) + yield + + +def _track_start(monkeypatch): + calls = {"n": 0} + + def _start(): + calls["n"] += 1 + return {"started": True, "reason": None, "job": {"state": "running"}} + + monkeypatch.setattr(rl, "start_update", _start) + return calls + + +# --------------------------------------------------------------------------- # +# update_confirm token unit tests +# --------------------------------------------------------------------------- # + +def test_token_roundtrip_single_use(): + tok, exp = _uc.mint_confirm_token("b9909") + assert isinstance(tok, str) and tok + ok, reason = _uc.consume_confirm_token(tok, "b9909") + assert ok and reason is None + # Single use: a replay is refused. + ok2, reason2 = _uc.consume_confirm_token(tok, "b9909") + assert ok2 is False and reason2 == "invalid_token" + + +def test_token_stale_target_refused(): + tok, _ = _uc.mint_confirm_token("b9909") + ok, reason = _uc.consume_confirm_token(tok, "b9910") # offered build changed + assert ok is False and reason == "stale_target" + + +def test_token_expired_refused(): + tok, _ = _uc.mint_confirm_token("b9909", ttl_seconds = 0) + ok, reason = _uc.consume_confirm_token(tok, "b9909") + assert ok is False and reason == "expired_token" + + +def test_token_missing_refused(): + ok, reason = _uc.consume_confirm_token(None, "b9909") + assert ok is False and reason == "invalid_token" + + +# --------------------------------------------------------------------------- # +# Handler-level: the swap only runs with an explicit confirmation +# --------------------------------------------------------------------------- # + +def test_apply_without_confirmation_is_refused_and_never_swaps(monkeypatch): + calls = _track_start(monkeypatch) + out = asyncio.run(rl.llama_update(request = None, current_subject = "operator")) + assert out.started is False + assert out.reason == "confirmation_required" + assert "swap" in out.message.lower() or "confirm" in out.message.lower() + # The result still names the host + build so the refusal is actionable. + assert out.machine.hostname + assert out.latest_tag == "b9909" + assert calls["n"] == 0 # installer NEVER started + + +def test_apply_with_confirmed_true_proceeds(monkeypatch): + calls = _track_start(monkeypatch) + body = rl.LlamaUpdateRequest(confirmed = True) + out = asyncio.run(rl.llama_update(request = body, current_subject = "operator")) + assert out.started is True + assert out.machine.hostname # which machine + assert out.latest_tag == "b9909" # which version + assert calls["n"] == 1 + + +def test_apply_with_valid_token_proceeds(monkeypatch): + calls = _track_start(monkeypatch) + tok, _ = _uc.mint_confirm_token("b9909") + body = rl.LlamaUpdateRequest(confirm_token = tok) + out = asyncio.run(rl.llama_update(request = body, current_subject = "operator")) + assert out.started is True + assert calls["n"] == 1 + # Token is single-use: replaying the same body is refused, no second swap. + out2 = asyncio.run(rl.llama_update(request = body, current_subject = "operator")) + assert out2.started is False + assert out2.reason == "invalid_token" + assert calls["n"] == 1 + + +def test_apply_with_stale_token_is_refused(monkeypatch): + calls = _track_start(monkeypatch) + tok, _ = _uc.mint_confirm_token("b9900") # bound to an older offered build + body = rl.LlamaUpdateRequest(confirm_token = tok) + out = asyncio.run(rl.llama_update(request = body, current_subject = "operator")) + assert out.started is False + assert out.reason == "stale_target" + assert calls["n"] == 0 + + +def test_confirm_endpoint_describes_and_mints(monkeypatch): + out = asyncio.run(rl.llama_update_confirm(current_subject = "operator")) + assert out.update_available is True + assert out.appliable is True + assert out.installed_tag == "b9860" + assert out.latest_tag == "b9909" + assert out.machine.hostname + assert out.confirm_token + # The freshly minted token applies the update end to end. + calls = _track_start(monkeypatch) + body = rl.LlamaUpdateRequest(confirm_token = out.confirm_token) + applied = asyncio.run(rl.llama_update(request = body, current_subject = "operator")) + assert applied.started is True + assert calls["n"] == 1 + + +def test_confirm_endpoint_up_to_date_offers_no_token(monkeypatch): + monkeypatch.setattr( + rl, + "get_update_status", + lambda force_refresh = False: { + "update_available": False, + "installed_tag": "b9909", + "latest_tag": "b9909", + "job": {"state": "idle"}, + }, + ) + out = asyncio.run(rl.llama_update_confirm(current_subject = "operator")) + assert out.update_available is False + assert out.appliable is False + assert out.confirm_token is None + + +def test_status_reports_machine(): + out = asyncio.run( + rl.llama_update_status(force_refresh = False, current_subject = "operator") + ) + assert out.machine.hostname + assert out.machine.platform + assert out.latest_tag == "b9909" + + +# --------------------------------------------------------------------------- # +# HTTP-level: auth gate + wiring + backwards-compatible bodyless POST +# --------------------------------------------------------------------------- # + +def _client(): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + app = FastAPI() + app.include_router(rl.router, prefix = "/api/llama") + return TestClient(app, raise_server_exceptions = True) + + +def test_http_unauthenticated_update_is_refused(monkeypatch): + calls = _track_start(monkeypatch) + client = _client() + # No Authorization header at all -> 401 from the auth dependency. + r = client.post("/api/llama/update", json = {"confirmed": True}) + assert r.status_code == 401 + assert calls["n"] == 0 # installer never reached + + +def test_http_authenticated_bodyless_post_refused_no_swap(monkeypatch): + # Backwards compat: an OLD frontend posts /update with no body. That must be + # safe -- refused (confirmation_required), NEVER a silent swap. + calls = _track_start(monkeypatch) + client = _client() + r = client.post("/api/llama/update", headers = {"Authorization": "Bearer good"}) + assert r.status_code == 200 + body = r.json() + assert body["started"] is False + assert body["reason"] == "confirmation_required" + assert body["machine"]["hostname"] + assert calls["n"] == 0 + + +def test_http_two_step_confirm_then_apply(monkeypatch): + calls = _track_start(monkeypatch) + client = _client() + h = {"Authorization": "Bearer good"} + + step1 = client.post("/api/llama/update/confirm", headers = h) + assert step1.status_code == 200 + tok = step1.json()["confirm_token"] + assert tok + + step2 = client.post("/api/llama/update", headers = h, json = {"confirm_token": tok}) + assert step2.status_code == 200 + assert step2.json()["started"] is True + assert step2.json()["machine"]["hostname"] + assert calls["n"] == 1 + + +def test_http_local_and_remote_behave_identically(monkeypatch): + # There is no same-machine axis: the contract depends only on auth + confirm, + # so a "local" and a "remote" authenticated caller get identical outcomes. + calls = _track_start(monkeypatch) + client = _client() + h = {"Authorization": "Bearer good"} + # Simulate a remote caller by adding proxy/forwarding headers that the closed + # PR would have treated as "not host" -- here they change nothing. + remote_h = {**h, "X-Forwarded-For": "203.0.113.7", "CF-Connecting-IP": "203.0.113.7"} + + local = client.post("/api/llama/update", headers = h, json = {"confirmed": True}) + remote = client.post("/api/llama/update", headers = remote_h, json = {"confirmed": True}) + assert local.json()["started"] == remote.json()["started"] == True + assert calls["n"] == 2 # both proceeded; location was never the gate diff --git a/studio/backend/utils/update_confirm.py b/studio/backend/utils/update_confirm.py new file mode 100644 index 0000000000..1735ba98c0 --- /dev/null +++ b/studio/backend/utils/update_confirm.py @@ -0,0 +1,104 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Single-use, short-lived confirmation tokens for the llama.cpp host-binary swap. + +The update runs an OS installer that replaces the binary on the machine running +Studio, so it must not fire from an unconfirmed click, a stale banner, or a replay. +Confirmation is required uniformly for every caller (local, desktop, or remote over +SSH/Cloudflare); we do not gate on "same machine" since a headless SSH server never +has a host-local session. A token binds the build it was offered for (``target_tag``) +and is single-use with a short TTL. The store is in-process, matching Studio's +single-process backend; for a multi-worker deploy swap it for an HMAC stateless token. +""" + +from __future__ import annotations + +import secrets +import threading +import time +from datetime import datetime, timezone +from typing import Optional, Tuple + +# How long a freshly minted confirmation token stays valid. +CONFIRM_TOKEN_TTL_SECONDS = 300 +# Cap the store so a burst of confirm calls that are never applied cannot grow +# memory without bound; oldest entries are evicted first. +_MAX_TOKENS = 64 + +_lock = threading.Lock() +# token -> (target_tag, expires_at_monotonic) +_tokens: "dict[str, Tuple[str, float]]" = {} + + +def _iso(ts_epoch: float) -> str: + return ( + datetime.fromtimestamp(ts_epoch, tz = timezone.utc) + .replace(microsecond = 0) + .isoformat() + .replace("+00:00", "Z") + ) + + +def _purge_expired_locked(now: float) -> None: + expired = [tok for tok, (_tag, exp) in _tokens.items() if exp <= now] + for tok in expired: + _tokens.pop(tok, None) + + +def mint_confirm_token( + target_tag: str, + *, + ttl_seconds: int = CONFIRM_TOKEN_TTL_SECONDS, +) -> Tuple[str, str]: + """Mint a single-use token bound to ``target_tag``. + + Returns ``(token, expires_at_iso)``. ``expires_at_iso`` is wall-clock UTC for + display; validity itself is tracked on a monotonic clock so a system time + change cannot extend or shorten it. + """ + now_mono = time.monotonic() + now_wall = time.time() + token = secrets.token_urlsafe(32) + with _lock: + _purge_expired_locked(now_mono) + if len(_tokens) >= _MAX_TOKENS: + oldest = min(_tokens, key = lambda t: _tokens[t][1]) # evict nearest expiry + _tokens.pop(oldest, None) + _tokens[token] = (target_tag, now_mono + ttl_seconds) + return token, _iso(now_wall + ttl_seconds) + + +def consume_confirm_token( + token: Optional[str], + current_target_tag: str, +) -> Tuple[bool, Optional[str]]: + """Validate and consume a token for a swap to ``current_target_tag``. + + Returns ``(ok, reason)``. On success ``(True, None)`` and the token is burned + (single use). On failure ``ok`` is False and ``reason`` is one of: + ``invalid_token`` (missing/unknown), ``expired_token``, ``stale_target`` + (bound to a different build than the one about to install). + """ + if not token: + return False, "invalid_token" + now_mono = time.monotonic() + with _lock: + # Pop first so any outcome consumes the token, and an expired hit still + # reports "expired" rather than collapsing to "invalid". + entry = _tokens.pop(token, None) + _purge_expired_locked(now_mono) + if entry is None: + return False, "invalid_token" + target_tag, expires_at = entry + if expires_at <= now_mono: + return False, "expired_token" + if target_tag != (current_target_tag or ""): + return False, "stale_target" + return True, None + + +def reset_tokens_for_tests() -> None: + """Test-only: clear the token store.""" + with _lock: + _tokens.clear() From cc3259d425402bb7f9b124bcffd922fda778ce5e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:40:47 +0000 Subject: [PATCH 002/219] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/routes/llama.py | 8 ++++---- studio/backend/tests/test_update_contract.py | 11 ++++++----- studio/backend/utils/update_confirm.py | 7 ++----- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/studio/backend/routes/llama.py b/studio/backend/routes/llama.py index 6d4b2415dc..d3d247eb2b 100644 --- a/studio/backend/routes/llama.py +++ b/studio/backend/routes/llama.py @@ -44,9 +44,7 @@ _REFUSAL_MESSAGES = { "The confirmation is missing or unrecognized. Re-check for the update and " "confirm again before it runs." ), - "expired_token": ( - "The confirmation expired. Re-check for the update and confirm again." - ), + "expired_token": ("The confirmation expired. Re-check for the update and confirm again."), "stale_target": ( "The available build changed since you confirmed. Re-check for the update " "and confirm the new build before it runs." @@ -284,7 +282,9 @@ async def llama_update( return LlamaUpdateActionResponse( started = False, reason = refuse_reason, - message = _REFUSAL_MESSAGES.get(refuse_reason, _REFUSAL_MESSAGES["confirmation_required"]), + message = _REFUSAL_MESSAGES.get( + refuse_reason, _REFUSAL_MESSAGES["confirmation_required"] + ), machine = machine, installed_tag = installed_tag, latest_tag = target_tag, diff --git a/studio/backend/tests/test_update_contract.py b/studio/backend/tests/test_update_contract.py index cf84df143c..81386dc503 100644 --- a/studio/backend/tests/test_update_contract.py +++ b/studio/backend/tests/test_update_contract.py @@ -162,6 +162,7 @@ def _track_start(monkeypatch): # update_confirm token unit tests # --------------------------------------------------------------------------- # + def test_token_roundtrip_single_use(): tok, exp = _uc.mint_confirm_token("b9909") assert isinstance(tok, str) and tok @@ -193,6 +194,7 @@ def test_token_missing_refused(): # Handler-level: the swap only runs with an explicit confirmation # --------------------------------------------------------------------------- # + def test_apply_without_confirmation_is_refused_and_never_swaps(monkeypatch): calls = _track_start(monkeypatch) out = asyncio.run(rl.llama_update(request = None, current_subject = "operator")) @@ -210,8 +212,8 @@ def test_apply_with_confirmed_true_proceeds(monkeypatch): body = rl.LlamaUpdateRequest(confirmed = True) out = asyncio.run(rl.llama_update(request = body, current_subject = "operator")) assert out.started is True - assert out.machine.hostname # which machine - assert out.latest_tag == "b9909" # which version + assert out.machine.hostname # which machine + assert out.latest_tag == "b9909" # which version assert calls["n"] == 1 @@ -273,9 +275,7 @@ def test_confirm_endpoint_up_to_date_offers_no_token(monkeypatch): def test_status_reports_machine(): - out = asyncio.run( - rl.llama_update_status(force_refresh = False, current_subject = "operator") - ) + out = asyncio.run(rl.llama_update_status(force_refresh = False, current_subject = "operator")) assert out.machine.hostname assert out.machine.platform assert out.latest_tag == "b9909" @@ -285,6 +285,7 @@ def test_status_reports_machine(): # HTTP-level: auth gate + wiring + backwards-compatible bodyless POST # --------------------------------------------------------------------------- # + def _client(): from fastapi import FastAPI from fastapi.testclient import TestClient diff --git a/studio/backend/utils/update_confirm.py b/studio/backend/utils/update_confirm.py index 1735ba98c0..342ebb81ac 100644 --- a/studio/backend/utils/update_confirm.py +++ b/studio/backend/utils/update_confirm.py @@ -47,9 +47,7 @@ def _purge_expired_locked(now: float) -> None: def mint_confirm_token( - target_tag: str, - *, - ttl_seconds: int = CONFIRM_TOKEN_TTL_SECONDS, + target_tag: str, *, ttl_seconds: int = CONFIRM_TOKEN_TTL_SECONDS ) -> Tuple[str, str]: """Mint a single-use token bound to ``target_tag``. @@ -70,8 +68,7 @@ def mint_confirm_token( def consume_confirm_token( - token: Optional[str], - current_target_tag: str, + token: Optional[str], current_target_tag: str ) -> Tuple[bool, Optional[str]]: """Validate and consume a token for a swap to ``current_target_tag``. From 3d7abda4343b7522793b89fd2b3860d2035cfa57 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 11:26:40 +0000 Subject: [PATCH 003/219] Studio: fix local-link confirm reason, token build binding, and wire the client Three fixes to the update-confirmation flow. The confirm endpoint checked update_available before local_link, so a local-link tree was reported as up_to_date instead of local_link. The apply route validated the confirm token against a cached update status while start_update re-resolved the latest tag, so a token minted for an older build could apply a newer one; force-refresh so both see the same target. And the frontend still posted the update with no token, which the new gate refuses, so the Update button did nothing; it now confirms to mint a single-use token and applies with it. --- studio/backend/routes/llama.py | 30 +++++++------ studio/backend/tests/test_update_contract.py | 45 +++++++++++++++++++ .../src/hooks/use-llama-update-check.ts | 29 +++++++++++- 3 files changed, 90 insertions(+), 14 deletions(-) diff --git a/studio/backend/routes/llama.py b/studio/backend/routes/llama.py index d3d247eb2b..10e89a27f1 100644 --- a/studio/backend/routes/llama.py +++ b/studio/backend/routes/llama.py @@ -209,17 +209,9 @@ async def llama_update_confirm( latest_tag = status.get("latest_tag") size = status.get("update_size_bytes") - if not status.get("update_available"): - return LlamaUpdateConfirmResponse( - update_available = False, - appliable = False, - reason = status.get("reason") or "up_to_date", - machine = machine, - installed_tag = installed_tag, - latest_tag = latest_tag, - update_size_bytes = size, - ) - # Update exists but the tree is a --with-llama-cpp-dir local link: describe, do not apply. + # A --with-llama-cpp-dir local link reports update_available=False, so this + # must run before the up_to_date branch below or the local_link reason is + # masked and callers are wrongly told there is no update. if status.get("local_link"): return LlamaUpdateConfirmResponse( update_available = True, @@ -230,6 +222,16 @@ async def llama_update_confirm( latest_tag = latest_tag, update_size_bytes = size, ) + if not status.get("update_available"): + return LlamaUpdateConfirmResponse( + update_available = False, + appliable = False, + reason = status.get("reason") or "up_to_date", + machine = machine, + installed_tag = installed_tag, + latest_tag = latest_tag, + update_size_bytes = size, + ) token, expires_at = mint_confirm_token(latest_tag or "") return LlamaUpdateConfirmResponse( @@ -260,8 +262,10 @@ async def llama_update( confirmation, not the caller's location, so a headless SSH server confirms like a local one.""" req = request or LlamaUpdateRequest() machine = _current_machine() - # Cached read: names the host and build for the prompt and binds the token. - status = await asyncio.to_thread(get_update_status) + # Force-refresh so the token is validated against the same build start_update + # will resolve: a stale cache would accept a token minted for an older tag and + # then install a newer one, bypassing the exact-build binding. + status = await asyncio.to_thread(get_update_status, force_refresh = True) installed_tag = status.get("installed_tag") target_tag = status.get("latest_tag") size = status.get("update_size_bytes") diff --git a/studio/backend/tests/test_update_contract.py b/studio/backend/tests/test_update_contract.py index 81386dc503..650ef3ef58 100644 --- a/studio/backend/tests/test_update_contract.py +++ b/studio/backend/tests/test_update_contract.py @@ -274,6 +274,51 @@ def test_confirm_endpoint_up_to_date_offers_no_token(monkeypatch): assert out.confirm_token is None +def test_confirm_endpoint_reports_local_link_not_up_to_date(monkeypatch): + # A --with-llama-cpp-dir tree reports local_link=True with update_available=False. + # The confirm endpoint must surface reason="local_link", not mask it behind the + # generic up_to_date refusal. + monkeypatch.setattr( + rl, + "get_update_status", + lambda force_refresh = False: { + "update_available": False, + "local_link": True, + "installed_tag": "b9909", + "latest_tag": None, + "job": {"state": "idle"}, + }, + ) + out = asyncio.run(rl.llama_update_confirm(current_subject = "operator")) + assert out.reason == "local_link" + assert out.appliable is False + assert out.confirm_token is None + + +def test_apply_revalidates_token_against_refreshed_target(monkeypatch): + # A token confirmed for b9909; a newer build b9910 publishes before apply. + # The apply must re-resolve the target and refuse the now-stale token rather + # than install a build the operator never confirmed. + def _status(force_refresh: bool = False): + return { + "supported": True, + "update_available": True, + "installed_tag": "b9860", + "latest_tag": "b9910" if force_refresh else "b9909", + "update_size_bytes": 42_000_000, + "job": {"state": "idle"}, + } + + monkeypatch.setattr(rl, "get_update_status", _status) + calls = _track_start(monkeypatch) + tok, _ = _uc.mint_confirm_token("b9909") + body = rl.LlamaUpdateRequest(confirm_token = tok) + out = asyncio.run(rl.llama_update(request = body, current_subject = "operator")) + assert out.started is False + assert out.reason == "stale_target" + assert calls["n"] == 0 # never installed the unconfirmed b9910 + + def test_status_reports_machine(): out = asyncio.run(rl.llama_update_status(force_refresh = False, current_subject = "operator")) assert out.machine.hostname diff --git a/studio/frontend/src/hooks/use-llama-update-check.ts b/studio/frontend/src/hooks/use-llama-update-check.ts index e3a735ffde..ba8a110e1c 100644 --- a/studio/frontend/src/hooks/use-llama-update-check.ts +++ b/studio/frontend/src/hooks/use-llama-update-check.ts @@ -311,8 +311,35 @@ export function useLlamaUpdateCheck({ message?: string | null; job?: unknown; } | null = null; + // Two-step apply: confirm to mint a single-use token bound to the offered + // build, then apply with it. A bare POST is refused with confirmation_required. + let confirmToken: string; try { - const res = await authFetch("/api/llama/update", { method: "POST" }); + const cres = await authFetch("/api/llama/update/confirm", { method: "POST" }); + if (!cres.ok) { + setApplying(false); + return { ok: false, error: `HTTP ${cres.status}` }; + } + const confirm = (await cres.json().catch(() => null)) as { + appliable?: boolean; + reason?: string | null; + confirm_token?: string | null; + } | null; + if (!confirm?.appliable || !confirm.confirm_token) { + setApplying(false); + return { ok: false, error: confirm?.reason ?? "update is not applicable" }; + } + confirmToken = confirm.confirm_token; + } catch (e) { + setApplying(false); + return { ok: false, error: String(e) }; + } + try { + const res = await authFetch("/api/llama/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm_token: confirmToken }), + }); if (!res.ok) { setApplying(false); return { ok: false, error: `HTTP ${res.status}` }; From ec9ba169a54f58ceb424c1230b6cd8951b340c8c Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 12:04:08 +0000 Subject: [PATCH 004/219] Studio: require an explicit confirmation before applying a llama.cpp update Complete the confirmation flow. The apply route now passes the confirmed target into start_update, which aborts with stale_target if the freshly resolved latest moved since confirmation, so it never installs a build the operator did not see. The client no longer applies on a single click: it fetches the target, shows a confirmation dialog naming the host and the from and to builds, and only applies on explicit acceptance. The result now carries the machine and versions so the toast names which host was updated. --- studio/backend/routes/llama.py | 4 +- studio/backend/tests/test_llama_route.py | 2 +- studio/backend/tests/test_update_contract.py | 114 +++++++++- studio/backend/utils/llama_cpp_update.py | 26 ++- .../src/components/llama-update-banner.tsx | 27 ++- .../llama-update-confirm-dialog.tsx | 99 +++++++++ .../src/features/chat/chat-settings-sheet.tsx | 19 +- .../src/hooks/use-llama-update-check.ts | 208 ++++++++++++------ 8 files changed, 413 insertions(+), 86 deletions(-) create mode 100644 studio/frontend/src/components/llama-update-confirm-dialog.tsx diff --git a/studio/backend/routes/llama.py b/studio/backend/routes/llama.py index 10e89a27f1..72f369ba39 100644 --- a/studio/backend/routes/llama.py +++ b/studio/backend/routes/llama.py @@ -296,7 +296,9 @@ async def llama_update( job = LlamaUpdateJob(**status.get("job", {})), ) - action = await asyncio.to_thread(start_update) + # Pass the confirmed target so the updater installs exactly that build and + # aborts if latest moved between this refresh and its own (see start_update). + action = await asyncio.to_thread(start_update, target_tag) return LlamaUpdateActionResponse( machine = machine, installed_tag = installed_tag, diff --git a/studio/backend/tests/test_llama_route.py b/studio/backend/tests/test_llama_route.py index ac3ec01797..a3e3a76c7a 100644 --- a/studio/backend/tests/test_llama_route.py +++ b/studio/backend/tests/test_llama_route.py @@ -123,7 +123,7 @@ def test_status_handler_runs_off_event_loop(monkeypatch): def test_update_handler_runs_off_event_loop(monkeypatch): seen = {} - def fake_start(): + def fake_start(expected_tag = None): seen["thread"] = threading.current_thread() return {"started": True, "reason": None, "job": {"state": "running"}} diff --git a/studio/backend/tests/test_update_contract.py b/studio/backend/tests/test_update_contract.py index 650ef3ef58..6b5009fb79 100644 --- a/studio/backend/tests/test_update_contract.py +++ b/studio/backend/tests/test_update_contract.py @@ -88,7 +88,7 @@ def _install_stubs(): "job": {"state": "idle"}, } - def _default_start(): + def _default_start(expected_tag = None): return {"started": True, "reason": None, "job": {"state": "running"}} lcu_mod.get_update_status = _default_status @@ -148,10 +148,11 @@ def _reset(monkeypatch): def _track_start(monkeypatch): - calls = {"n": 0} + calls = {"n": 0, "expected_tags": []} - def _start(): + def _start(expected_tag = None): calls["n"] += 1 + calls["expected_tags"].append(expected_tag) return {"started": True, "reason": None, "job": {"state": "running"}} monkeypatch.setattr(rl, "start_update", _start) @@ -215,6 +216,9 @@ def test_apply_with_confirmed_true_proceeds(monkeypatch): assert out.machine.hostname # which machine assert out.latest_tag == "b9909" # which version assert calls["n"] == 1 + # The confirmed target is threaded into start_update so it installs exactly + # that build (and aborts if latest moved since this refresh). + assert calls["expected_tags"] == ["b9909"] def test_apply_with_valid_token_proceeds(monkeypatch): @@ -326,6 +330,110 @@ def test_status_reports_machine(): assert out.latest_tag == "b9909" +# --------------------------------------------------------------------------- # +# start_update pins the confirmed target: a release that publishes in the gap +# after confirmation must not be installed in place of the confirmed build. +# --------------------------------------------------------------------------- # + + +def _load_real_llama_cpp_update(): + """Load the real utils.llama_cpp_update to exercise start_update's + confirmed-target guard directly. The module-level stubs shadow only + utils.llama_cpp_update, so this loads it under a private name (leaving that + stub in place for the handler tests) and its two real deps under their + production names.""" + + def _load(mod_name: str, filename: str): + spec = importlib.util.spec_from_file_location( + mod_name, str(_BACKEND / "utils" / filename) + ) + mod = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = mod + spec.loader.exec_module(mod) + return mod + + for mod_name, filename in ( + ("utils.llama_cpp_freshness", "llama_cpp_freshness.py"), + ("utils.process_lifetime", "process_lifetime.py"), + ): + if mod_name not in sys.modules: + _load(mod_name, filename) + return _load("real_llama_cpp_update", "llama_cpp_update.py") + + +def _stub_marker_update(monkeypatch, lcu, *, resolved_latest: str): + """Route start_update down the marker branch with a fixed freshly-resolved + latest tag; no network, no real install-root probing.""" + monkeypatch.setattr(lcu, "_find_binary", lambda: "/llama.cpp/build/bin/llama-server") + monkeypatch.setattr(lcu, "_active_install_is_local_link", lambda binary: False) + monkeypatch.setattr( + lcu, + "read_install_marker", + lambda binary: { + "tag": "b9860", + "published_repo": "unslothai/llama.cpp", + "asset": "cuda-x64.zip", + }, + ) + monkeypatch.setattr( + lcu, "_installer_script", lambda: Path("/fake/install_llama_prebuilt.py") + ) + monkeypatch.setattr(lcu, "_install_dir_for", lambda binary: Path("/llama.cpp/build")) + monkeypatch.setattr( + lcu, + "get_update_status", + lambda force_refresh = False: { + "update_available": True, + "installed_tag": "b9860", + "latest_tag": resolved_latest, + "job": {"state": "idle"}, + }, + ) + + +def test_start_update_aborts_when_resolved_latest_differs_from_confirmed(monkeypatch): + # Operator confirmed b9909, but a newer b9910 publishes before the updater + # re-resolves latest. The confirmed target must win: abort, never install. + lcu = _load_real_llama_cpp_update() + lcu._reset_job_for_tests() + _stub_marker_update(monkeypatch, lcu, resolved_latest = "b9910") + installs = {"n": 0} + monkeypatch.setattr( + lcu, "_run_update", lambda *a, **k: installs.__setitem__("n", installs["n"] + 1) + ) + out = lcu.start_update("b9909") + assert out["started"] is False + assert out["reason"] == "stale_target" + assert installs["n"] == 0 # never swapped to the unconfirmed b9910 + + +def test_start_update_proceeds_when_resolved_latest_matches_confirmed(monkeypatch): + # Confirmed target still matches the freshly-resolved latest: install it and + # pin the installer to exactly that build. + lcu = _load_real_llama_cpp_update() + lcu._reset_job_for_tests() + _stub_marker_update(monkeypatch, lcu, resolved_latest = "b9909") + spawned: dict = {} + + class _CapturingThread: + def __init__(self, *, target = None, args = (), name = None, daemon = None): + spawned["args"] = args + + def start(self): + spawned["started"] = True + + monkeypatch.setattr(lcu.threading, "Thread", _CapturingThread) + out = lcu.start_update("b9909") + assert out["started"] is True + assert out["reason"] is None + assert spawned.get("started") is True + # args = (install_dir, repo, asset, script, pin_release_tag); the installer + # is pinned to exactly the confirmed build (a pin is disabled on macOS). + if sys.platform != "darwin": + assert spawned["args"][4] == "b9909" + lcu._reset_job_for_tests() + + # --------------------------------------------------------------------------- # # HTTP-level: auth gate + wiring + backwards-compatible bodyless POST # --------------------------------------------------------------------------- # diff --git a/studio/backend/utils/llama_cpp_update.py b/studio/backend/utils/llama_cpp_update.py index 31dbda63ea..fe13f16da5 100644 --- a/studio/backend/utils/llama_cpp_update.py +++ b/studio/backend/utils/llama_cpp_update.py @@ -622,9 +622,15 @@ def _run_update( pass -def start_update() -> dict: +def start_update(expected_tag: Optional[str] = None) -> dict: """Kick off a background update. Idempotent: a second call while one is - running returns the in-flight job rather than starting another.""" + running returns the in-flight job rather than starting another. + + ``expected_tag`` is the build the caller already confirmed. The updater + re-resolves "latest" itself, so a release published in the gap after + confirmation would move the target; when the freshly-resolved latest differs + from ``expected_tag`` this aborts rather than swapping to a build the caller + never confirmed. Left None, it behaves as before (no target pinning).""" binary = _find_binary() # Refuse to update a --with-llama-cpp-dir local link: installing a prebuilt # here would write through the link into the user's own checkout (or fail) @@ -678,6 +684,7 @@ def start_update() -> dict: # (skipping too-new prebuilts); elsewhere an unusable latest now fails # the job loudly (retryable) instead of walking back. pin_release_tag = None if sys.platform == "darwin" else status.get("latest_tag") + resolved_tag = status.get("latest_tag") else: # Source build / custom path: only proceed when the same detection logic # would offer the update (prebuilt exists, install is behind, root is @@ -708,6 +715,21 @@ def start_update() -> dict: # No pin: source-build detection resolves via --resolve-prebuilt latest, # the same resolver the unpinned apply uses, so the two already agree. pin_release_tag = None + resolved_tag = src.get("latest_tag") + + # Install exactly the build the caller confirmed. A release published in the + # gap since confirmation moves the freshly-resolved latest above, so abort + # rather than swap to a build the caller never saw or confirmed. + if expected_tag is not None and resolved_tag != expected_tag: + return { + "started": False, + "reason": "stale_target", + "message": ( + "The available llama.cpp build changed since it was confirmed. " + "Re-check for the update and confirm the new build before it runs." + ), + "job": get_update_status()["job"], + } if install_dir is None: return { diff --git a/studio/frontend/src/components/llama-update-banner.tsx b/studio/frontend/src/components/llama-update-banner.tsx index 3db15ffe30..05c0f94cd8 100644 --- a/studio/frontend/src/components/llama-update-banner.tsx +++ b/studio/frontend/src/components/llama-update-banner.tsx @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { Button } from "@/components/ui/button"; +import { useLlamaUpdateConfirmGate } from "@/components/llama-update-confirm-dialog"; import { resyncInferenceStatusAfterServerModelChange } from "@/features/chat"; import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check"; import { useShowLlamaUpdateBanner } from "@/hooks/use-llama-update-pref"; @@ -91,16 +92,23 @@ export function LlamaUpdateBanner({ enabled, onReloadRequired: resyncInferenceStatusAfterServerModelChange, }); + // Explicit accept/cancel prompt naming the exact build + host before the swap. + const { requestConfirm, dialog } = useLlamaUpdateConfirmGate(); async function handleUpdate() { - const result = await apply(); + const result = await apply(requestConfirm); if (result?.ok) { - const updatedTag = result.tag ?? status?.latest_tag ?? "the latest build"; + const host = result.machine?.hostname; + const where = host ? ` on ${host}` : ""; + const fromTag = result.fromTag ?? status?.installed_tag ?? "unknown"; + const toTag = result.tag ?? status?.latest_tag ?? "the latest build"; const reloadHint = result.reloadRequired ? " Reload your model to use it." : ""; - toast.success(`llama.cpp updated to ${updatedTag}.${reloadHint}`); - } else if (result) { + toast.success( + `llama.cpp${where} updated ${fromTag} to ${toTag}.${reloadHint}`, + ); + } else if (result && result.error !== "canceled") { toast.error( `llama.cpp update failed: ${result.error ?? "unknown error"}`, ); @@ -127,7 +135,7 @@ export function LlamaUpdateBanner({ ); // Avoid opacity/transform transitions; GPU layer churn can flash. - return show ? ( + const banner = show ? (
) : null; + + // The confirm dialog renders unconditionally (inert until Update is clicked) + // so it survives the banner hiding once the swap begins. + return ( + <> + {dialog} + {banner} + + ); } diff --git a/studio/frontend/src/components/llama-update-confirm-dialog.tsx b/studio/frontend/src/components/llama-update-confirm-dialog.tsx new file mode 100644 index 0000000000..caee3f1529 --- /dev/null +++ b/studio/frontend/src/components/llama-update-confirm-dialog.tsx @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import type { LlamaApplyTarget } from "@/hooks/use-llama-update-check"; +import { useCallback, useRef, useState, type ReactElement } from "react"; + +interface LlamaUpdateConfirmGate { + /** + * Open the confirmation prompt for `target` and resolve true only on an + * explicit accept, false on cancel/dismiss. Pass this as the `apply()` gate so + * the destructive host-binary swap never runs without a visible, accepted + * build + target host. + */ + requestConfirm: (target: LlamaApplyTarget) => Promise; + /** Render once in the tree; inert until requestConfirm opens it. */ + dialog: ReactElement; +} + +/** + * Reusable accept/cancel gate for the llama.cpp host-binary swap. Shows the + * exact build (from -> to) and the machine the swap targets, and only resolves + * true when the user explicitly accepts. + */ +export function useLlamaUpdateConfirmGate(): LlamaUpdateConfirmGate { + const [target, setTarget] = useState(null); + const resolveRef = useRef<((accepted: boolean) => void) | null>(null); + + const decide = useCallback((accepted: boolean) => { + const resolve = resolveRef.current; + resolveRef.current = null; + setTarget(null); + resolve?.(accepted); + }, []); + + const requestConfirm = useCallback((next: LlamaApplyTarget) => { + // A still-open prior prompt (e.g. a double click) resolves as declined. + resolveRef.current?.(false); + setTarget(next); + return new Promise((resolve) => { + resolveRef.current = resolve; + }); + }, []); + + const machineLabel = target?.machine?.hostname?.trim() || "this machine"; + + const dialog = ( + { + if (!open) decide(false); + }} + > + + + Update llama.cpp? + + This downloads and swaps the llama.cpp binary on {machineLabel}. It + replaces the running build, so only continue if you started this + update. + + +
+

+ {target?.fromTag ?? "unknown"} →{" "} + + {target?.toTag ?? "the latest build"} + +

+ {target?.machine?.hostname && ( +

+ {target.machine.hostname} + {target.machine.platform ? ` · ${target.machine.platform}` : ""} +

+ )} +
+ + decide(false)}> + Cancel + + decide(true)}> + Update + + +
+
+ ); + + return { requestConfirm, dialog }; +} diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index cedd298ecf..f63d4595d2 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -57,6 +57,7 @@ import { InfoHint } from "@/components/ui/info-hint"; import { Tooltip, TooltipContent } from "@/components/ui/tooltip"; import { useIsMobile } from "@/hooks/use-mobile"; import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check"; +import { useLlamaUpdateConfirmGate } from "@/components/llama-update-confirm-dialog"; import { cn } from "@/lib/utils"; import { ArrowTurnBackwardIcon, @@ -570,19 +571,28 @@ export function ChatSettingsPanel({ enabled: mtpUpdatable, onReloadRequired: resyncInferenceStatusAfterServerModelChange, }); + // Explicit accept/cancel prompt naming the exact build + host before the swap. + const { + requestConfirm: requestLlamaUpdateConfirm, + dialog: llamaUpdateConfirmDialog, + } = useLlamaUpdateConfirmGate(); const handleMtpUpdate = useCallback(async () => { - const result = await applyLlamaUpdate(); + const result = await applyLlamaUpdate(requestLlamaUpdateConfirm); if (result.ok) { + const host = result.machine?.hostname; + const where = host ? ` on ${host}` : ""; + const fromTag = result.fromTag ?? llamaUpdateStatus?.installed_tag ?? "unknown"; + const toTag = result.tag ?? llamaUpdateStatus?.latest_tag ?? "the latest build"; const reloadHint = result.reloadRequired ? " Reload your model to enable MTP." : ""; toast.success( - `llama.cpp updated to ${result.tag ?? "the latest build"}.${reloadHint}`, + `llama.cpp${where} updated ${fromTag} to ${toTag}.${reloadHint}`, ); - } else { + } else if (result.error !== "canceled") { toast.error(`llama.cpp update failed: ${result.error ?? "unknown error"}`); } - }, [applyLlamaUpdate]); + }, [applyLlamaUpdate, requestLlamaUpdateConfirm, llamaUpdateStatus]); const specDraftNMax = useChatRuntimeStore((s) => s.specDraftNMax); const setSpecDraftNMax = useChatRuntimeStore((s) => s.setSpecDraftNMax); const loadedSpecDraftNMax = useChatRuntimeStore( @@ -1887,6 +1897,7 @@ export function ChatSettingsPanel({ + {llamaUpdateConfirmDialog} ); diff --git a/studio/frontend/src/hooks/use-llama-update-check.ts b/studio/frontend/src/hooks/use-llama-update-check.ts index ba8a110e1c..dc41a05621 100644 --- a/studio/frontend/src/hooks/use-llama-update-check.ts +++ b/studio/frontend/src/hooks/use-llama-update-check.ts @@ -120,13 +120,41 @@ interface UseLlamaUpdateCheckOptions { onReloadRequired?: () => void; } +export interface LlamaMachine { + hostname: string; + platform: string; +} + +/** The pending swap a confirmation prompt describes: the exact build (from -> + * to), the host it targets, and the single-use token that applies it. */ +export interface LlamaApplyTarget { + token: string; + machine: LlamaMachine | null; + fromTag: string | null; + toTag: string | null; +} + export interface LlamaApplyResult { ok: boolean; + // Post-swap build tag (the "to" version). tag?: string | null; + // Build the swap replaced (the "from" version). + fromTag?: string | null; + // Host the swap ran on, so the result can name which machine changed. + machine?: LlamaMachine | null; reloadRequired?: boolean | null; error?: string | null; } +function parseMachine(value: unknown): LlamaMachine | null { + if (!value || typeof value !== "object") return null; + const m = value as Record; + return { + hostname: typeof m.hostname === "string" ? m.hostname : "", + platform: typeof m.platform === "string" ? m.platform : "", + }; +} + /** Tracks llama.cpp update visibility and apply progress. */ export function useLlamaUpdateCheck({ enabled = true, @@ -301,81 +329,121 @@ export function useLlamaUpdateCheck({ }, SNOOZE_DELAY_MS); }, [surfaceIfAvailable]); - const apply = useCallback(async (): Promise => { - if (applying) return { ok: false, error: "already running" }; - setApplying(true); - setVisible(true); - let action: { - started?: boolean; - reason?: string | null; - message?: string | null; - job?: unknown; - } | null = null; - // Two-step apply: confirm to mint a single-use token bound to the offered - // build, then apply with it. A bare POST is refused with confirmation_required. - let confirmToken: string; - try { - const cres = await authFetch("/api/llama/update/confirm", { method: "POST" }); - if (!cres.ok) { - setApplying(false); - return { ok: false, error: `HTTP ${cres.status}` }; - } - const confirm = (await cres.json().catch(() => null)) as { - appliable?: boolean; - reason?: string | null; - confirm_token?: string | null; - } | null; - if (!confirm?.appliable || !confirm.confirm_token) { - setApplying(false); - return { ok: false, error: confirm?.reason ?? "update is not applicable" }; - } - confirmToken = confirm.confirm_token; - } catch (e) { - setApplying(false); - return { ok: false, error: String(e) }; - } - try { - const res = await authFetch("/api/llama/update", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ confirm_token: confirmToken }), - }); - if (!res.ok) { - setApplying(false); - return { ok: false, error: `HTTP ${res.status}` }; - } + const apply = useCallback( + async ( + confirm: (target: LlamaApplyTarget) => boolean | Promise, + ): Promise => { + if (applying) return { ok: false, error: "already running" }; + + // Step 1: describe the pending swap. /confirm force-refreshes and mints a + // single-use token bound to the offered build, and reports the host + the + // from/to tags. No install starts here, so `applying` stays false while the + // confirmation prompt is open. + let target: LlamaApplyTarget; try { - action = await res.json(); - } catch { - action = null; + const cres = await authFetch("/api/llama/update/confirm", { + method: "POST", + }); + if (!cres.ok) return { ok: false, error: `HTTP ${cres.status}` }; + const confirmResp = (await cres.json().catch(() => null)) as { + appliable?: boolean; + reason?: string | null; + confirm_token?: string | null; + machine?: unknown; + installed_tag?: string | null; + latest_tag?: string | null; + } | null; + if (!confirmResp?.appliable || !confirmResp.confirm_token) { + return { + ok: false, + error: confirmResp?.reason ?? "update is not applicable", + }; + } + target = { + token: confirmResp.confirm_token, + machine: parseMachine(confirmResp.machine), + fromTag: + typeof confirmResp.installed_tag === "string" + ? confirmResp.installed_tag + : null, + toTag: + typeof confirmResp.latest_tag === "string" + ? confirmResp.latest_tag + : null, + }; + } catch (e) { + return { ok: false, error: String(e) }; } - } catch (e) { - setApplying(false); - return { ok: false, error: String(e) }; - } - // Non-started jobs stay idle; already_running is tracked below. - if ( - action && - action.started === false && - action.reason !== "already_running" - ) { - // A stale banner's click can land after another tab already applied the - // update (e.g. "up_to_date"): the response still carries that tab's - // completed job, so process reload_required here too, not just from the - // poll path -- otherwise this rejection silently drops it. - notifyReloadIfNeeded(parseJob(action.job)); - setApplying(false); + // Step 2: require an explicit user confirmation of the exact build + host + // before the destructive binary swap. Declining aborts, untouched. + const accepted = await confirm(target); + if (!accepted) return { ok: false, error: "canceled" }; + + // Step 3: apply with the confirmed single-use token. + setApplying(true); + setVisible(true); + let action: { + started?: boolean; + reason?: string | null; + message?: string | null; + job?: unknown; + } | null = null; + try { + const res = await authFetch("/api/llama/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm_token: target.token }), + }); + if (!res.ok) { + setApplying(false); + return { ok: false, error: `HTTP ${res.status}` }; + } + try { + action = await res.json(); + } catch { + action = null; + } + } catch (e) { + setApplying(false); + return { ok: false, error: String(e) }; + } + + // Non-started jobs stay idle; already_running is tracked below. + if ( + action && + action.started === false && + action.reason !== "already_running" + ) { + // A stale banner's click can land after another tab already applied the + // update (e.g. "up_to_date"): the response still carries that tab's + // completed job, so process reload_required here too, not just from the + // poll path -- otherwise this rejection silently drops it. + notifyReloadIfNeeded(parseJob(action.job)); + setApplying(false); + return { + ok: false, + error: action.message ?? action.reason ?? "update was not started", + machine: target.machine, + fromTag: target.fromTag, + tag: target.toTag, + }; + } + + // Surface the confirmed host + from/to build alongside the job's post-swap + // tag, so the result can report what changed and where. + const polled = await new Promise((resolve) => + startJobPoll(resolve), + ); return { - ok: false, - error: action.message ?? action.reason ?? "update was not started", + ...polled, + machine: target.machine, + fromTag: target.fromTag, + tag: polled.tag ?? target.toTag, }; - } - - return await new Promise((resolve) => - startJobPoll(resolve), - ); - }, [applying, startJobPoll, notifyReloadIfNeeded]); + }, + [applying, startJobPoll, notifyReloadIfNeeded], + ); return { status: enabled ? status : null, From ff074472fde2779a458c405c562f1dc52bfa8969 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:05:53 +0000 Subject: [PATCH 005/219] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/tests/test_update_contract.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/studio/backend/tests/test_update_contract.py b/studio/backend/tests/test_update_contract.py index 6b5009fb79..d6fd0ffe2c 100644 --- a/studio/backend/tests/test_update_contract.py +++ b/studio/backend/tests/test_update_contract.py @@ -344,9 +344,7 @@ def _load_real_llama_cpp_update(): production names.""" def _load(mod_name: str, filename: str): - spec = importlib.util.spec_from_file_location( - mod_name, str(_BACKEND / "utils" / filename) - ) + spec = importlib.util.spec_from_file_location(mod_name, str(_BACKEND / "utils" / filename)) mod = importlib.util.module_from_spec(spec) sys.modules[mod_name] = mod spec.loader.exec_module(mod) @@ -375,9 +373,7 @@ def _stub_marker_update(monkeypatch, lcu, *, resolved_latest: str): "asset": "cuda-x64.zip", }, ) - monkeypatch.setattr( - lcu, "_installer_script", lambda: Path("/fake/install_llama_prebuilt.py") - ) + monkeypatch.setattr(lcu, "_installer_script", lambda: Path("/fake/install_llama_prebuilt.py")) monkeypatch.setattr(lcu, "_install_dir_for", lambda binary: Path("/llama.cpp/build")) monkeypatch.setattr( lcu, @@ -416,7 +412,14 @@ def test_start_update_proceeds_when_resolved_latest_matches_confirmed(monkeypatc spawned: dict = {} class _CapturingThread: - def __init__(self, *, target = None, args = (), name = None, daemon = None): + def __init__( + self, + *, + target = None, + args = (), + name = None, + daemon = None, + ): spawned["args"] = args def start(self): From 251bec8e583ed6d35ec665f84fbfc1d0680ec194 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 12:50:27 +0000 Subject: [PATCH 006/219] Pin the source-build llama.cpp install to the resolved release tag The source-build path left the installer unpinned, so a release published between the resolve here and the installer's own latest re-resolve could install a build the user never confirmed. Pin to the host-aware resolver's release_tag (any macOS walk-back already applied), which cannot disable a needed walk-back and arms the post-install tag check. --- studio/backend/tests/test_llama_cpp_update.py | 61 ++++++++++++++++++- studio/backend/utils/llama_cpp_update.py | 12 +++- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/studio/backend/tests/test_llama_cpp_update.py b/studio/backend/tests/test_llama_cpp_update.py index 83ea07a066..86614606a1 100644 --- a/studio/backend/tests/test_llama_cpp_update.py +++ b/studio/backend/tests/test_llama_cpp_update.py @@ -393,9 +393,64 @@ def test_start_update_source_build_installs_prebuilt(monkeypatch, tmp_path): assert "--llama-tag" in cmd and "latest" in cmd assert cmd[cmd.index("--rocm-gfx") + 1] == "gfx110x" assert "--simple-policy" not in cmd and "--cpu-fallback" not in cmd - # No pin: source-build detection and the unpinned apply share the same - # "latest" resolver, so they already agree. - assert "--published-release-tag" not in cmd + # Pin to the release the host-aware resolver already picked, so a release + # published between resolve and the installer's own "latest" re-resolve + # cannot swap in an unconfirmed build (matches the marker path's pin). + assert "--published-release-tag" in cmd + assert cmd[cmd.index("--published-release-tag") + 1] == "b9585" + + +def test_start_update_source_build_pins_resolver_release_tag(monkeypatch, tmp_path): + # The source-build apply must pin the installer to the release the host-aware + # resolver picked (res release_tag), not the display tag. For a fork-wrapper + # release the two differ ("v1.0" release vs "b9457" display); only the real + # release tag is a valid --published-release-tag and post-install anchor. + # Confirming the displayed tag must still proceed and pin the real release. + install_dir = tmp_path / "llama.cpp" + binary = install_dir / "build" / "bin" / "llama-server" + binary.parent.mkdir(parents = True) + binary.write_text("stub") # no marker -> source-build path + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_PATH", raising = False) + monkeypatch.setattr(upd, "_find_binary", lambda: str(binary)) + monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py") + monkeypatch.setattr(upd, "_installed_build_number", lambda b: 9000) # behind b9457 + _prebuilt( + monkeypatch, + repo = "unslothai/llama.cpp", + release_tag = "v1.0", + llama_tag = "b9457", + asset = "llama-b9457-bin-linux-x64.tar.gz", + ) + + captured = {} + + class _Proc: + returncode = 0 + stdout = "installed" + stderr = "" + + def _fake_run(cmd, **kwargs): + return _Proc() + + def _on_start(cmd): + captured["cmd"] = cmd + # Installer writes the marker for the pinned release (real tag v1.0). + _write_install(install_dir, "b9457", release_tag = "v1.0") + + monkeypatch.setattr(upd.subprocess, "run", _fake_run) + _patch_installer_popen(monkeypatch, on_start = _on_start) + + res = upd.start_update(expected_tag = "b9457") # the displayed/confirmed tag + assert res["started"] is True, res + deadline = time.time() + 10 + while time.time() < deadline: + if upd.get_update_status()["job"]["state"] in ("success", "error"): + break + time.sleep(0.05) + cmd = captured["cmd"] + assert "--published-release-tag" in cmd + assert cmd[cmd.index("--published-release-tag") + 1] == "v1.0" + assert upd.get_update_status()["job"]["state"] == "success" def test_start_update_happy_path(monkeypatch, tmp_path): diff --git a/studio/backend/utils/llama_cpp_update.py b/studio/backend/utils/llama_cpp_update.py index fe13f16da5..dd80d8ef7e 100644 --- a/studio/backend/utils/llama_cpp_update.py +++ b/studio/backend/utils/llama_cpp_update.py @@ -712,9 +712,15 @@ def start_update(expected_tag: Optional[str] = None) -> dict: repo = (res or {}).get("repo") or DEFAULT_PUBLISHED_REPO from_tag = None asset = (res or {}).get("asset") - # No pin: source-build detection resolves via --resolve-prebuilt latest, - # the same resolver the unpinned apply uses, so the two already agree. - pin_release_tag = None + # Pin the installer to the exact release the host-aware resolver already + # picked (res release_tag, which is the real GitHub release tag and has + # any macOS walk-back already applied), so a release published between + # this resolve and the installer's own "latest" re-resolve cannot swap in + # an unconfirmed build. The pinned tag is host-compatible by construction, + # so pinning does not disable a needed walk-back (unlike the marker path); + # it also arms the post-install tag check in _run_update. Falls back to + # unpinned only if the resolver reported no release tag. + pin_release_tag = (res or {}).get("release_tag") or None resolved_tag = src.get("latest_tag") # Install exactly the build the caller confirmed. A release published in the From c99b89b9d242284098454a66f7ed1d8268732c19 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 18:52:59 +0000 Subject: [PATCH 007/219] studio: tighten comments in the llama.cpp update confirmation flow --- studio/backend/routes/llama.py | 31 +++++------ studio/backend/tests/test_llama_cpp_update.py | 14 +++-- studio/backend/tests/test_update_contract.py | 52 +++++++------------ studio/backend/utils/llama_cpp_update.py | 28 +++++----- studio/backend/utils/update_confirm.py | 12 ++--- .../src/components/llama-update-banner.tsx | 4 +- .../llama-update-confirm-dialog.tsx | 12 ++--- .../src/hooks/use-llama-update-check.ts | 11 ++-- 8 files changed, 66 insertions(+), 98 deletions(-) diff --git a/studio/backend/routes/llama.py b/studio/backend/routes/llama.py index 72f369ba39..1f3f0cc039 100644 --- a/studio/backend/routes/llama.py +++ b/studio/backend/routes/llama.py @@ -53,7 +53,7 @@ _REFUSAL_MESSAGES = { class UpdateMachine(BaseModel): - """Host a swap targets, so a remote operator sees which machine would change.""" + """The host a swap targets, so a remote operator sees which machine changes.""" hostname: str = Field("", description = "Hostname of the machine running Studio.") platform: str = Field( @@ -112,8 +112,7 @@ class LlamaUpdateStatusResponse(BaseModel): class LlamaUpdateRequest(BaseModel): - """Body for POST /update. Empty body means no confirmation, so the swap is - refused (safe default) and a stale banner or replay cannot swap the binary.""" + """Body for POST /update. Empty body = no confirmation, so the swap is refused.""" confirm_token: Optional[str] = Field( None, @@ -165,8 +164,7 @@ _last_llama_update_step = -1 def _log_llama_update_progress(job: LlamaUpdateJob) -> None: - """One llama_update_progress line per 10% step so a prebuilt update reports - progress without a line per poll. Resyncs when a new update starts.""" + """Log one progress line per 10% step, not per poll; resyncs on a new update.""" global _last_llama_update_step if job.state != "running" or job.progress is None: return @@ -199,9 +197,9 @@ async def llama_update_status( async def llama_update_confirm( current_subject: str = Depends(get_current_subject), ) -> LlamaUpdateConfirmResponse: - """Step one of the two-step apply: describe the pending swap and, when it can - be applied, mint a single-use token bound to the offered build. Available to - any authenticated operator; confirmation, not location, is the gate.""" + """Step one of the two-step apply: describe the pending swap and, when + appliable, mint a single-use token bound to the offered build. The gate is + confirmation, not caller location.""" # Force-refresh so the token binds the current build, not a stale cached tag. status = await asyncio.to_thread(get_update_status, force_refresh = True) machine = _current_machine() @@ -209,9 +207,8 @@ async def llama_update_confirm( latest_tag = status.get("latest_tag") size = status.get("update_size_bytes") - # A --with-llama-cpp-dir local link reports update_available=False, so this - # must run before the up_to_date branch below or the local_link reason is - # masked and callers are wrongly told there is no update. + # A --with-llama-cpp-dir local link reports update_available=False, so this must + # run before the up_to_date branch or the local_link reason gets masked. if status.get("local_link"): return LlamaUpdateConfirmResponse( update_available = True, @@ -256,15 +253,13 @@ async def llama_update( """Apply the swap, but only with an explicit, fresh confirmation. The installer replaces the host binary, so a caller must either echo the - single-use ``confirm_token`` from POST /update/confirm (preferred: replay-safe, - bound to the build) or send ``confirmed=true`` (non-interactive callers). With - neither, the swap is refused and the binary is left untouched. The gate is the - confirmation, not the caller's location, so a headless SSH server confirms like a local one.""" + single-use ``confirm_token`` from POST /update/confirm (replay-safe, build-bound) + or send ``confirmed=true`` (non-interactive callers); with neither, the swap is + refused untouched. The gate is confirmation, not the caller's location.""" req = request or LlamaUpdateRequest() machine = _current_machine() - # Force-refresh so the token is validated against the same build start_update - # will resolve: a stale cache would accept a token minted for an older tag and - # then install a newer one, bypassing the exact-build binding. + # Force-refresh so the token is validated against the same build start_update will + # resolve; a stale cache could accept an old-tag token then install a newer build. status = await asyncio.to_thread(get_update_status, force_refresh = True) installed_tag = status.get("installed_tag") target_tag = status.get("latest_tag") diff --git a/studio/backend/tests/test_llama_cpp_update.py b/studio/backend/tests/test_llama_cpp_update.py index 86614606a1..43a4eb5f29 100644 --- a/studio/backend/tests/test_llama_cpp_update.py +++ b/studio/backend/tests/test_llama_cpp_update.py @@ -393,19 +393,17 @@ def test_start_update_source_build_installs_prebuilt(monkeypatch, tmp_path): assert "--llama-tag" in cmd and "latest" in cmd assert cmd[cmd.index("--rocm-gfx") + 1] == "gfx110x" assert "--simple-policy" not in cmd and "--cpu-fallback" not in cmd - # Pin to the release the host-aware resolver already picked, so a release - # published between resolve and the installer's own "latest" re-resolve - # cannot swap in an unconfirmed build (matches the marker path's pin). + # Pin to the release the resolver picked, so one published before the installer's + # own re-resolve can't swap in an unconfirmed build (matches the marker path). assert "--published-release-tag" in cmd assert cmd[cmd.index("--published-release-tag") + 1] == "b9585" def test_start_update_source_build_pins_resolver_release_tag(monkeypatch, tmp_path): - # The source-build apply must pin the installer to the release the host-aware - # resolver picked (res release_tag), not the display tag. For a fork-wrapper - # release the two differ ("v1.0" release vs "b9457" display); only the real - # release tag is a valid --published-release-tag and post-install anchor. - # Confirming the displayed tag must still proceed and pin the real release. + # The source-build apply pins the installer to the resolver's release_tag, not the + # display tag. For a fork wrapper they differ ("v1.0" vs "b9457"); only the real + # release tag is a valid --published-release-tag. Confirming the displayed tag + # must still proceed and pin the real release. install_dir = tmp_path / "llama.cpp" binary = install_dir / "build" / "bin" / "llama-server" binary.parent.mkdir(parents = True) diff --git a/studio/backend/tests/test_update_contract.py b/studio/backend/tests/test_update_contract.py index d6fd0ffe2c..e9562bb5f6 100644 --- a/studio/backend/tests/test_update_contract.py +++ b/studio/backend/tests/test_update_contract.py @@ -10,9 +10,8 @@ Properties covered: - unauthenticated -> refused (401), NO swap - stale / expired / replayed token -> refused, NO swap -routes/llama.py is loaded standalone with stubbed auth / loggers / llama_cpp_update -and the real utils.update_confirm, so no heavy backend deps are needed. Both handler -calls and a real FastAPI TestClient (HTTP + auth gate) are exercised. +routes/llama.py loads standalone with stubbed auth/loggers/llama_cpp_update and the +real utils.update_confirm. Exercised via handler calls and a FastAPI TestClient. """ from __future__ import annotations @@ -33,10 +32,8 @@ _BACKEND = _HERE.parent def _install_stubs(): - """Register stub packages so routes/llama.py imports cleanly, plus the real - update_confirm module under its production name.""" - # auth.authentication.get_current_subject -> a real FastAPI dependency that - # 401s without a valid bearer, so the HTTP tests can prove the auth gate. + """Stub packages so routes/llama.py imports cleanly, plus the real update_confirm.""" + # auth.get_current_subject: a real FastAPI dep that 401s without a valid bearer. from fastapi import Header, HTTPException def get_current_subject(authorization: Optional[str] = Header(default = None)) -> str: @@ -159,9 +156,7 @@ def _track_start(monkeypatch): return calls -# --------------------------------------------------------------------------- # # update_confirm token unit tests -# --------------------------------------------------------------------------- # def test_token_roundtrip_single_use(): @@ -191,9 +186,7 @@ def test_token_missing_refused(): assert ok is False and reason == "invalid_token" -# --------------------------------------------------------------------------- # # Handler-level: the swap only runs with an explicit confirmation -# --------------------------------------------------------------------------- # def test_apply_without_confirmation_is_refused_and_never_swaps(monkeypatch): @@ -279,9 +272,8 @@ def test_confirm_endpoint_up_to_date_offers_no_token(monkeypatch): def test_confirm_endpoint_reports_local_link_not_up_to_date(monkeypatch): - # A --with-llama-cpp-dir tree reports local_link=True with update_available=False. - # The confirm endpoint must surface reason="local_link", not mask it behind the - # generic up_to_date refusal. + # A --with-llama-cpp-dir tree reports local_link=True with update_available=False; + # the confirm endpoint must surface reason="local_link", not the generic up_to_date. monkeypatch.setattr( rl, "get_update_status", @@ -300,9 +292,8 @@ def test_confirm_endpoint_reports_local_link_not_up_to_date(monkeypatch): def test_apply_revalidates_token_against_refreshed_target(monkeypatch): - # A token confirmed for b9909; a newer build b9910 publishes before apply. - # The apply must re-resolve the target and refuse the now-stale token rather - # than install a build the operator never confirmed. + # Token confirmed for b9909; b9910 publishes before apply. The apply must + # re-resolve and refuse the now-stale token, not install an unconfirmed build. def _status(force_refresh: bool = False): return { "supported": True, @@ -330,18 +321,14 @@ def test_status_reports_machine(): assert out.latest_tag == "b9909" -# --------------------------------------------------------------------------- # -# start_update pins the confirmed target: a release that publishes in the gap -# after confirmation must not be installed in place of the confirmed build. -# --------------------------------------------------------------------------- # +# start_update pins the confirmed target: a release publishing after confirmation +# must not be installed in place of the confirmed build. def _load_real_llama_cpp_update(): - """Load the real utils.llama_cpp_update to exercise start_update's - confirmed-target guard directly. The module-level stubs shadow only - utils.llama_cpp_update, so this loads it under a private name (leaving that - stub in place for the handler tests) and its two real deps under their - production names.""" + """Load the real utils.llama_cpp_update under a private name (leaving the + module-level stub for the handler tests) plus its two real deps, to exercise + start_update's confirmed-target guard directly.""" def _load(mod_name: str, filename: str): spec = importlib.util.spec_from_file_location(mod_name, str(_BACKEND / "utils" / filename)) @@ -437,9 +424,7 @@ def test_start_update_proceeds_when_resolved_latest_matches_confirmed(monkeypatc lcu._reset_job_for_tests() -# --------------------------------------------------------------------------- # # HTTP-level: auth gate + wiring + backwards-compatible bodyless POST -# --------------------------------------------------------------------------- # def _client(): @@ -461,8 +446,8 @@ def test_http_unauthenticated_update_is_refused(monkeypatch): def test_http_authenticated_bodyless_post_refused_no_swap(monkeypatch): - # Backwards compat: an OLD frontend posts /update with no body. That must be - # safe -- refused (confirmation_required), NEVER a silent swap. + # Backwards compat: an OLD frontend posts /update with no body -> refused + # (confirmation_required), never a silent swap. calls = _track_start(monkeypatch) client = _client() r = client.post("/api/llama/update", headers = {"Authorization": "Bearer good"}) @@ -492,13 +477,12 @@ def test_http_two_step_confirm_then_apply(monkeypatch): def test_http_local_and_remote_behave_identically(monkeypatch): - # There is no same-machine axis: the contract depends only on auth + confirm, - # so a "local" and a "remote" authenticated caller get identical outcomes. + # No same-machine axis: the contract depends only on auth + confirm, so local + # and remote authenticated callers get identical outcomes. calls = _track_start(monkeypatch) client = _client() h = {"Authorization": "Bearer good"} - # Simulate a remote caller by adding proxy/forwarding headers that the closed - # PR would have treated as "not host" -- here they change nothing. + # Proxy/forwarding headers a "not host" gate would key on change nothing here. remote_h = {**h, "X-Forwarded-For": "203.0.113.7", "CF-Connecting-IP": "203.0.113.7"} local = client.post("/api/llama/update", headers = h, json = {"confirmed": True}) diff --git a/studio/backend/utils/llama_cpp_update.py b/studio/backend/utils/llama_cpp_update.py index dd80d8ef7e..ee1d415272 100644 --- a/studio/backend/utils/llama_cpp_update.py +++ b/studio/backend/utils/llama_cpp_update.py @@ -626,11 +626,10 @@ def start_update(expected_tag: Optional[str] = None) -> dict: """Kick off a background update. Idempotent: a second call while one is running returns the in-flight job rather than starting another. - ``expected_tag`` is the build the caller already confirmed. The updater - re-resolves "latest" itself, so a release published in the gap after - confirmation would move the target; when the freshly-resolved latest differs - from ``expected_tag`` this aborts rather than swapping to a build the caller - never confirmed. Left None, it behaves as before (no target pinning).""" + ``expected_tag`` is the build the caller confirmed. Since the updater re-resolves + "latest", a release published after confirmation could move the target; when the + freshly-resolved latest differs from ``expected_tag`` this aborts rather than swap + an unconfirmed build. None -> no target pinning (prior behaviour).""" binary = _find_binary() # Refuse to update a --with-llama-cpp-dir local link: installing a prebuilt # here would write through the link into the user's own checkout (or fail) @@ -712,20 +711,17 @@ def start_update(expected_tag: Optional[str] = None) -> dict: repo = (res or {}).get("repo") or DEFAULT_PUBLISHED_REPO from_tag = None asset = (res or {}).get("asset") - # Pin the installer to the exact release the host-aware resolver already - # picked (res release_tag, which is the real GitHub release tag and has - # any macOS walk-back already applied), so a release published between - # this resolve and the installer's own "latest" re-resolve cannot swap in - # an unconfirmed build. The pinned tag is host-compatible by construction, - # so pinning does not disable a needed walk-back (unlike the marker path); - # it also arms the post-install tag check in _run_update. Falls back to - # unpinned only if the resolver reported no release tag. + # Pin the installer to the release the host-aware resolver already picked (res + # release_tag: the real GitHub tag with any macOS walk-back applied), so a + # release published before the installer's own re-resolve can't swap in an + # unconfirmed build. Host-compatible by construction, so pinning disables no + # needed walk-back (unlike the marker path); also arms _run_update's post- + # install tag check. Unpinned only if the resolver reported no release tag. pin_release_tag = (res or {}).get("release_tag") or None resolved_tag = src.get("latest_tag") - # Install exactly the build the caller confirmed. A release published in the - # gap since confirmation moves the freshly-resolved latest above, so abort - # rather than swap to a build the caller never saw or confirmed. + # Install exactly the build the caller confirmed: a release published since + # confirmation moves latest above, so abort rather than swap an unconfirmed build. if expected_tag is not None and resolved_tag != expected_tag: return { "started": False, diff --git a/studio/backend/utils/update_confirm.py b/studio/backend/utils/update_confirm.py index 342ebb81ac..3bb7493945 100644 --- a/studio/backend/utils/update_confirm.py +++ b/studio/backend/utils/update_confirm.py @@ -5,11 +5,10 @@ The update runs an OS installer that replaces the binary on the machine running Studio, so it must not fire from an unconfirmed click, a stale banner, or a replay. -Confirmation is required uniformly for every caller (local, desktop, or remote over -SSH/Cloudflare); we do not gate on "same machine" since a headless SSH server never -has a host-local session. A token binds the build it was offered for (``target_tag``) -and is single-use with a short TTL. The store is in-process, matching Studio's -single-process backend; for a multi-worker deploy swap it for an HMAC stateless token. +Confirmation is required uniformly for every caller (not gated on "same machine", +since a headless SSH server has no host-local session). A token binds its offered +build (``target_tag``), is single-use with a short TTL, and lives in-process +(matching the single-process backend; use an HMAC stateless token for multi-worker). """ from __future__ import annotations @@ -22,8 +21,7 @@ from typing import Optional, Tuple # How long a freshly minted confirmation token stays valid. CONFIRM_TOKEN_TTL_SECONDS = 300 -# Cap the store so a burst of confirm calls that are never applied cannot grow -# memory without bound; oldest entries are evicted first. +# Cap the store so unapplied confirm calls can't grow memory unbounded; oldest first. _MAX_TOKENS = 64 _lock = threading.Lock() diff --git a/studio/frontend/src/components/llama-update-banner.tsx b/studio/frontend/src/components/llama-update-banner.tsx index 05c0f94cd8..75e5c38efb 100644 --- a/studio/frontend/src/components/llama-update-banner.tsx +++ b/studio/frontend/src/components/llama-update-banner.tsx @@ -238,8 +238,8 @@ export function LlamaUpdateBanner({ ) : null; - // The confirm dialog renders unconditionally (inert until Update is clicked) - // so it survives the banner hiding once the swap begins. + // Render the confirm dialog unconditionally (inert until Update) so it survives + // the banner hiding once the swap begins. return ( <> {dialog} diff --git a/studio/frontend/src/components/llama-update-confirm-dialog.tsx b/studio/frontend/src/components/llama-update-confirm-dialog.tsx index caee3f1529..ffbf6dd416 100644 --- a/studio/frontend/src/components/llama-update-confirm-dialog.tsx +++ b/studio/frontend/src/components/llama-update-confirm-dialog.tsx @@ -16,10 +16,9 @@ import { useCallback, useRef, useState, type ReactElement } from "react"; interface LlamaUpdateConfirmGate { /** - * Open the confirmation prompt for `target` and resolve true only on an - * explicit accept, false on cancel/dismiss. Pass this as the `apply()` gate so - * the destructive host-binary swap never runs without a visible, accepted - * build + target host. + * Open the prompt for `target`; resolve true only on explicit accept, false on + * cancel/dismiss. Pass as the `apply()` gate so the destructive swap never runs + * without a visible, accepted build + target host. */ requestConfirm: (target: LlamaApplyTarget) => Promise; /** Render once in the tree; inert until requestConfirm opens it. */ @@ -27,9 +26,8 @@ interface LlamaUpdateConfirmGate { } /** - * Reusable accept/cancel gate for the llama.cpp host-binary swap. Shows the - * exact build (from -> to) and the machine the swap targets, and only resolves - * true when the user explicitly accepts. + * Accept/cancel gate for the llama.cpp host-binary swap: shows the exact build + * (from -> to) and target machine, resolving true only on explicit accept. */ export function useLlamaUpdateConfirmGate(): LlamaUpdateConfirmGate { const [target, setTarget] = useState(null); diff --git a/studio/frontend/src/hooks/use-llama-update-check.ts b/studio/frontend/src/hooks/use-llama-update-check.ts index dc41a05621..b9bffb68c1 100644 --- a/studio/frontend/src/hooks/use-llama-update-check.ts +++ b/studio/frontend/src/hooks/use-llama-update-check.ts @@ -125,8 +125,8 @@ export interface LlamaMachine { platform: string; } -/** The pending swap a confirmation prompt describes: the exact build (from -> - * to), the host it targets, and the single-use token that applies it. */ +/** The pending swap a confirm prompt describes: build (from -> to), target host, + * and the single-use token that applies it. */ export interface LlamaApplyTarget { token: string; machine: LlamaMachine | null; @@ -335,10 +335,9 @@ export function useLlamaUpdateCheck({ ): Promise => { if (applying) return { ok: false, error: "already running" }; - // Step 1: describe the pending swap. /confirm force-refreshes and mints a - // single-use token bound to the offered build, and reports the host + the - // from/to tags. No install starts here, so `applying` stays false while the - // confirmation prompt is open. + // Step 1: describe the pending swap. /confirm force-refreshes, mints a + // single-use token bound to the offered build, and reports host + from/to tags. + // No install starts here, so `applying` stays false while the prompt is open. let target: LlamaApplyTarget; try { const cres = await authFetch("/api/llama/update/confirm", { From 4b6a6c774816c6b0454b38136bc6e1e0b7dd31d4 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Mon, 20 Jul 2026 07:55:46 +0000 Subject: [PATCH 008/219] Stop the update-contract test stubs from leaking into the shared suite test_update_contract installs stub auth/loggers/utils modules into sys.modules at collection time so routes/llama.py loads standalone, but it never removed them. In the one-process backend suite that poisoned every later test: a bare auth package broke 'from auth import storage' and 'from auth.authentication import create_access_token', and the NopLogger stub (no error method) broke code paths that log errors. Snapshot the affected sys.modules entries before installing the stubs and restore them right after the route module has bound its imports; the loaded route keeps the stub references it captured, so the contract tests are unchanged. Add a regression test asserting no installed stub is left in sys.modules. Mirrors the pop-after-load hygiene already in test_llama_route. --- studio/backend/tests/test_update_contract.py | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/studio/backend/tests/test_update_contract.py b/studio/backend/tests/test_update_contract.py index e9562bb5f6..aaa2795aaf 100644 --- a/studio/backend/tests/test_update_contract.py +++ b/studio/backend/tests/test_update_contract.py @@ -30,6 +30,19 @@ pytest.importorskip("fastapi") _HERE = Path(__file__).resolve().parent _BACKEND = _HERE.parent +# Stub modules _install_stubs writes into the shared sys.modules, tracked so they +# are restored after the route loads and a regression test can assert none leak +# into the rest of the one-process backend suite. +_INSTALLED_STUBS: dict[str, types.ModuleType] = {} +_STUBBED_KEYS = ( + "auth", + "auth.authentication", + "loggers", + "utils", + "utils.llama_cpp_update", + "utils.update_confirm", +) + def _install_stubs(): """Stub packages so routes/llama.py imports cleanly, plus the real update_confirm.""" @@ -67,6 +80,7 @@ def _install_stubs(): utils_pkg = types.ModuleType("utils") utils_pkg.__path__ = [] sys.modules["utils"] = utils_pkg + _INSTALLED_STUBS["utils"] = utils_pkg lcu_mod = types.ModuleType("utils.llama_cpp_update") @@ -105,10 +119,16 @@ def _install_stubs(): ("utils.llama_cpp_update", lcu_mod), ("utils.update_confirm", uc_mod), ): + _INSTALLED_STUBS[name] = mod sys.modules[name] = mod return uc_mod +# Snapshot the real modules before installing the collection-time stubs, then +# restore them once the route has bound its stubbed imports. Leaving the stubs in +# sys.modules poisons the shared process: a bare ``auth`` package and a NopLogger +# break every later test that imports the real auth/loggers/utils modules. +_saved_modules = {name: sys.modules.get(name) for name in _STUBBED_KEYS} _uc = _install_stubs() @@ -124,6 +144,13 @@ def _load_route(): rl = _load_route() +# rl now holds its stub references; put the real modules back so nothing leaks. +for _name, _orig in _saved_modules.items(): + if _orig is None: + sys.modules.pop(_name, None) + else: + sys.modules[_name] = _orig + @pytest.fixture(autouse = True) def _reset(monkeypatch): @@ -489,3 +516,12 @@ def test_http_local_and_remote_behave_identically(monkeypatch): remote = client.post("/api/llama/update", headers = remote_h, json = {"confirmed": True}) assert local.json()["started"] == remote.json()["started"] == True assert calls["n"] == 2 # both proceeded; location was never the gate + + +def test_collection_stubs_do_not_leak_into_sys_modules(): + # Regression: the collection-time stubs (bare auth package, NopLogger loggers, + # default update fns) must be removed from sys.modules after the route loads. + # Leaving them poisons the one-process backend suite -- every later test that + # imports the real auth/loggers/utils modules fails with "unknown location". + for name, stub in _INSTALLED_STUBS.items(): + assert sys.modules.get(name) is not stub, f"{name} stub leaked into sys.modules" From 27f3473c7eb4930c7aadce20945d3d6984029411 Mon Sep 17 00:00:00 2001 From: oobabooga Date: Tue, 21 Jul 2026 02:39:43 -0300 Subject: [PATCH 009/219] Studio: make tab navigation feel immediate (#7271) * Studio: make repeated tab switches feel immediate * Keep cached Studio navigation data fresh * Make first Studio tab visits responsive * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Serve range requests uncompressed for immutable assets (PR #7271) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: test Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- studio/backend/main.py | 36 ++++++- studio/backend/tests/test_middleware.py | 66 ++++++++++++ studio/frontend/src/app/auth-guards.ts | 61 ++++++++--- studio/frontend/src/app/routes/__root.tsx | 2 +- .../frontend/src/app/routes/data-recipes.tsx | 10 +- studio/frontend/src/app/routes/export.tsx | 10 +- studio/frontend/src/app/routes/hub.tsx | 16 +-- studio/frontend/src/app/routes/projects.tsx | 10 +- studio/frontend/src/app/routes/studio.tsx | 10 +- .../frontend/src/components/app-sidebar.tsx | 46 +++++++- .../src/features/chat/api/chat-api.ts | 14 ++- .../features/chat/hooks/use-chat-projects.ts | 100 ++++++++++++++---- .../features/data-recipes/data/recipes-db.ts | 46 +++++++- .../src/features/data-recipes/index.ts | 1 + .../export/export-navigation-cache.ts | 61 +++++++++++ .../src/features/export/export-page.tsx | 50 +++++---- .../hub/hooks/use-hub-paginated-search.ts | 48 +++++++-- studio/frontend/src/features/hub/hub-page.tsx | 36 ++++++- 18 files changed, 516 insertions(+), 107 deletions(-) create mode 100644 studio/frontend/src/features/export/export-navigation-cache.ts diff --git a/studio/backend/main.py b/studio/backend/main.py index f686e29bf5..48675b9539 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -289,6 +289,7 @@ from fastapi import Depends, FastAPI, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, HTMLResponse, Response +from starlette.middleware.gzip import GZipMiddleware from pathlib import Path from datetime import datetime @@ -1509,6 +1510,34 @@ def _should_inject_bootstrap(request: Request) -> bool: return _is_local_bootstrap_request(request) +_IMMUTABLE_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable" + + +class ImmutableStaticFiles(StaticFiles): + """Serve Vite's content-hashed assets without browser revalidation.""" + + def file_response( + self, + full_path, + stat_result, + scope, + status_code = 200, + ): + response = super().file_response(full_path, stat_result, scope, status_code) + response.headers["Cache-Control"] = _IMMUTABLE_ASSET_CACHE_CONTROL + return response + + +class _AssetGZipMiddleware(GZipMiddleware): + """Serve range requests uncompressed; gzip + 206 mislabels Content-Range.""" + + async def __call__(self, scope, receive, send): + if scope["type"] == "http" and any(key == b"range" for key, _ in scope["headers"]): + await self.app(scope, receive, send) + return + await super().__call__(scope, receive, send) + + def setup_frontend(app: FastAPI, build_path: Path): """Mount frontend static files (optional)""" if not build_path.exists(): @@ -1516,7 +1545,12 @@ def setup_frontend(app: FastAPI, build_path: Path): assets_dir = build_path / "assets" if assets_dir.exists(): - app.mount("/assets", StaticFiles(directory = assets_dir), name = "assets") + assets_app = _AssetGZipMiddleware( + ImmutableStaticFiles(directory = assets_dir), + minimum_size = 1024, + compresslevel = 6, + ) + app.mount("/assets", assets_app, name = "assets") def _build_index_response(request: Request) -> Response: content = (build_path / "index.html").read_bytes() diff --git a/studio/backend/tests/test_middleware.py b/studio/backend/tests/test_middleware.py index 11aeee6d77..209c6cb90a 100644 --- a/studio/backend/tests/test_middleware.py +++ b/studio/backend/tests/test_middleware.py @@ -14,6 +14,7 @@ import pytest from fastapi import FastAPI, HTTPException, Request from fastapi.responses import Response from fastapi.testclient import TestClient +from starlette.middleware.gzip import GZipMiddleware _BACKEND_ROOT = Path(__file__).resolve().parents[1] @@ -471,6 +472,71 @@ class TestSecurityHeadersMiddleware: assert b"server" in names +class TestFrontendAssets: + def test_hashed_assets_are_compressed_and_cached(self, tmp_path, main_module): + content = b"export const value = 'responsive';\n" * 200 + (tmp_path / "page-abc123.js").write_bytes(content) + app = FastAPI() + assets_app = GZipMiddleware( + main_module.ImmutableStaticFiles(directory = tmp_path), + minimum_size = 1024, + compresslevel = 6, + ) + app.mount("/assets", assets_app, name = "assets") + + response = TestClient(app).get( + "/assets/page-abc123.js", + headers = {"Accept-Encoding": "gzip"}, + ) + + assert response.status_code == 200 + assert response.content == content + assert response.headers["content-encoding"] == "gzip" + assert response.headers["cache-control"] == (main_module._IMMUTABLE_ASSET_CACHE_CONTROL) + assert "accept-encoding" in response.headers["vary"].lower() + + def test_asset_revalidation_keeps_immutable_cache_header(self, tmp_path, main_module): + (tmp_path / "page-abc123.js").write_text("export {};", encoding = "utf-8") + app = FastAPI() + app.mount( + "/assets", + main_module.ImmutableStaticFiles(directory = tmp_path), + name = "assets", + ) + client = TestClient(app) + first = client.get("/assets/page-abc123.js") + + response = client.get( + "/assets/page-abc123.js", + headers = {"If-None-Match": first.headers["etag"]}, + ) + + assert response.status_code == 304 + assert response.headers["cache-control"] == (main_module._IMMUTABLE_ASSET_CACHE_CONTROL) + + def test_range_request_is_not_compressed(self, tmp_path, main_module): + content = b"export const value = 'responsive';\n" * 200 + (tmp_path / "page-abc123.js").write_bytes(content) + app = FastAPI() + assets_app = main_module._AssetGZipMiddleware( + main_module.ImmutableStaticFiles(directory = tmp_path), + minimum_size = 1024, + compresslevel = 6, + ) + app.mount("/assets", assets_app, name = "assets") + + response = TestClient(app).get( + "/assets/page-abc123.js", + headers = {"Accept-Encoding": "gzip", "Range": "bytes=0-99"}, + ) + + assert response.status_code == 206 + assert response.headers.get("content-encoding") != "gzip" + assert response.headers["content-range"] == f"bytes 0-99/{len(content)}" + assert response.content == content[:100] + assert response.headers["cache-control"] == (main_module._IMMUTABLE_ASSET_CACHE_CONTROL) + + # /api/health auth gate diff --git a/studio/frontend/src/app/auth-guards.ts b/studio/frontend/src/app/auth-guards.ts index 6849f380b8..a3523ac580 100644 --- a/studio/frontend/src/app/auth-guards.ts +++ b/studio/frontend/src/app/auth-guards.ts @@ -23,19 +23,47 @@ interface AuthStatus { requires_password_change: boolean; } +const AUTH_STATUS_TTL_MS = 30_000; +let authStatusCheckedAt = 0; +let authStatusRequest: Promise | null = null; + +function hasFreshAuthStatus(): boolean { + return ( + authStatusCheckedAt !== 0 && + Date.now() - authStatusCheckedAt < AUTH_STATUS_TTL_MS + ); +} + async function fetchAuthStatus(): Promise { - try { - const res = await fetch(apiUrl("/api/auth/status")); - if (!res.ok) return { initialized: true, requires_password_change: mustChangePassword() }; - const status = (await res.json()) as AuthStatus; - // Server truth wins; keep localStorage in sync both ways. - if (status.requires_password_change !== mustChangePassword()) { - setMustChangePassword(status.requires_password_change); + if (authStatusRequest) return authStatusRequest; + + const request = (async () => { + try { + const res = await fetch(apiUrl("/api/auth/status")); + if (!res.ok) { + return { + initialized: true, + requires_password_change: mustChangePassword(), + }; + } + const status = (await res.json()) as AuthStatus; + authStatusCheckedAt = Date.now(); + // Server truth wins; keep localStorage in sync both ways. + if (status.requires_password_change !== mustChangePassword()) { + setMustChangePassword(status.requires_password_change); + } + return status; + } catch { + return { + initialized: true, + requires_password_change: mustChangePassword(), + }; } - return status; - } catch { - return { initialized: true, requires_password_change: mustChangePassword() }; - } + })().finally(() => { + authStatusRequest = null; + }); + authStatusRequest = request; + return request; } function authRedirect(to: "/login" | "/change-password"): never { @@ -49,12 +77,17 @@ export async function requireAuth(): Promise { } if (await hasActiveSession()) { - const { requires_password_change } = await fetchAuthStatus(); - if (requires_password_change || mustChangePassword()) { - authRedirect("/change-password"); + // Reconcile periodically so local-only routes cannot outlive a server-side + // password-change requirement, while nearby route switches stay local. + if (mustChangePassword() || !hasFreshAuthStatus()) { + const { requires_password_change } = await fetchAuthStatus(); + if (requires_password_change || mustChangePassword()) { + authRedirect("/change-password"); + } } return; } + const status = await fetchAuthStatus(); if (status.requires_password_change || mustChangePassword()) { authRedirect("/change-password"); diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index e23892e020..57e890dd5a 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -281,7 +281,7 @@ function RootLayout() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - transition={{ duration: 0.15 }} + transition={{ duration: 0.06 }} className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-visible" > }> diff --git a/studio/frontend/src/app/routes/data-recipes.tsx b/studio/frontend/src/app/routes/data-recipes.tsx index c35e63da5f..22f87821af 100644 --- a/studio/frontend/src/app/routes/data-recipes.tsx +++ b/studio/frontend/src/app/routes/data-recipes.tsx @@ -1,15 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -import { createRoute } from "@tanstack/react-router"; -import { lazy } from "react"; +import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; import { requireAuth } from "../auth-guards"; import { Route as rootRoute } from "./__root"; -const DataRecipesPage = lazy(() => - import("@/features/data-recipes").then((m) => ({ - default: m.DataRecipesPage, - })), +const DataRecipesPage = lazyRouteComponent( + () => import("@/features/data-recipes"), + "DataRecipesPage", ); export const Route = createRoute({ diff --git a/studio/frontend/src/app/routes/export.tsx b/studio/frontend/src/app/routes/export.tsx index 40118c6a92..5a7b586f19 100644 --- a/studio/frontend/src/app/routes/export.tsx +++ b/studio/frontend/src/app/routes/export.tsx @@ -1,15 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -import { createRoute } from "@tanstack/react-router"; -import { lazy } from "react"; +import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; import { requireAuth } from "../auth-guards"; import { Route as rootRoute } from "./__root"; -const ExportPage = lazy(() => - import("@/features/export/export-page").then((m) => ({ - default: m.ExportPage, - })), +const ExportPage = lazyRouteComponent( + () => import("@/features/export/export-page"), + "ExportPage", ); export type ExportSearch = { diff --git a/studio/frontend/src/app/routes/hub.tsx b/studio/frontend/src/app/routes/hub.tsx index c623ef9848..2207490e44 100644 --- a/studio/frontend/src/app/routes/hub.tsx +++ b/studio/frontend/src/app/routes/hub.tsx @@ -1,15 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -import { createRoute } from "@tanstack/react-router"; -import { lazy } from "react"; +import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; import { requireAuth } from "../auth-guards"; import { Route as rootRoute } from "./__root"; -const ModelsPage = lazy(() => - import("@/features/hub/hub-page").then((m) => ({ - default: m.ModelsPage, - })), +const ModelsPage = lazyRouteComponent( + () => import("@/features/hub/hub-page"), + "ModelsPage", ); export interface ModelsSearch { @@ -31,7 +29,11 @@ export const Route = createRoute({ const model = search.model; if (typeof model === "string" && model.length > 0) next.model = model; const section = search.section; - if (section === "trending" || section === "latest" || section === "finetune") { + if ( + section === "trending" || + section === "latest" || + section === "finetune" + ) { next.section = section; } const kind = search.kind; diff --git a/studio/frontend/src/app/routes/projects.tsx b/studio/frontend/src/app/routes/projects.tsx index c63b1d5838..17f58ef631 100644 --- a/studio/frontend/src/app/routes/projects.tsx +++ b/studio/frontend/src/app/routes/projects.tsx @@ -1,15 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -import { createRoute } from "@tanstack/react-router"; -import { lazy } from "react"; +import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; import { requireAuth } from "../auth-guards"; import { Route as rootRoute } from "./__root"; -const ProjectsPage = lazy(() => - import("@/features/chat/projects-page").then((m) => ({ - default: m.ProjectsPage, - })), +const ProjectsPage = lazyRouteComponent( + () => import("@/features/chat/projects-page"), + "ProjectsPage", ); export const Route = createRoute({ diff --git a/studio/frontend/src/app/routes/studio.tsx b/studio/frontend/src/app/routes/studio.tsx index ae7f445e94..798044bf64 100644 --- a/studio/frontend/src/app/routes/studio.tsx +++ b/studio/frontend/src/app/routes/studio.tsx @@ -1,15 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -import { createRoute } from "@tanstack/react-router"; -import { lazy } from "react"; +import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; import { requireAuth } from "../auth-guards"; import { Route as rootRoute } from "./__root"; -const StudioPage = lazy(() => - import("@/features/studio/studio-page").then((m) => ({ - default: m.StudioPage, - })), +const StudioPage = lazyRouteComponent( + () => import("@/features/studio/studio-page"), + "StudioPage", ); export const Route = createRoute({ diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index b8601b00f6..8eab03133b 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -93,7 +93,12 @@ import { import { Tooltip as TooltipPrimitive } from "radix-ui"; import { HugeiconsIcon } from "@hugeicons/react"; import { ChevronDown, Moon } from "lucide-react"; -import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; +import { + Link, + useNavigate, + useRouter, + useRouterState, +} from "@tanstack/react-router"; import { archiveChatItem, ChatSearchDialog, @@ -256,6 +261,10 @@ function createNavigationNonce(): string { return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } +function preloadSilently(request: Promise): void { + void request.catch(() => undefined); +} + function NavItem({ icon, label, @@ -267,6 +276,7 @@ function NavItem({ className, spinner, tooltip, + onIntent, }: { icon: typeof ZapIcon; label: string; @@ -277,6 +287,7 @@ function NavItem({ dataTour?: string; className?: string; spinner?: boolean; + onIntent?: () => void; // Overrides the hover tooltip (defaults to `label`). Used to explain why a // disabled item (e.g. Train/Export on a chat-only host) is greyed out. tooltip?: string; @@ -288,6 +299,8 @@ function NavItem({ tooltip={tooltip ?? label} disabled={disabled} onClick={onClick} + onPointerEnter={disabled ? undefined : onIntent} + onFocus={disabled ? undefined : onIntent} isActive={active} data-tour={dataTour} className="sidebar-nav-btn h-[33px] rounded-full gap-[8.5px] pl-3 pr-2.5 font-medium group-data-[collapsible=icon]:px-2.5 group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:mx-auto" @@ -324,6 +337,7 @@ export function AppSidebar() { }); const { togglePinned, isMobile, setOpenMobile } = useSidebar(); const navigate = useNavigate(); + const router = useRouter(); // Web update detection: `webUpdate` is non-null only when the installed // (PyPI) version is behind the latest release, so the card is hidden by @@ -1218,6 +1232,9 @@ export function AppSidebar() { navigate({ to: "/projects" }); closeMobileIfOpen(); }} + onIntent={() => { + preloadSilently(router.preloadRoute({ to: "/projects" })); + }} className="group/projects-item relative" > - - - Unpin - - - ) : null} ); } diff --git a/studio/frontend/src/components/assistant-ui/model-selector/remembered-load-settings.ts b/studio/frontend/src/components/assistant-ui/model-selector/remembered-load-settings.ts deleted file mode 100644 index 08492ab480..0000000000 --- a/studio/frontend/src/components/assistant-ui/model-selector/remembered-load-settings.ts +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - -// Per-model pre-load inference settings, persisted in localStorage so the load -// dialog can offer "Remember settings for ". GGUF picks only: every -// field is a llama.cpp load knob, so all save/restore call sites gate on -// GGUF-ness (a non-GGUF blob would only snapshot leftover standing values). - -const KEY = "unsloth_load_settings"; - -export interface RememberedLoadSettings { - contextLength: number | null; - kvCacheDtype: string | null; - speculativeType: string | null; - specDraftNMax: number | null; - tensorParallel: boolean; - // GPU Memory controls. Optional so an older blob (which lacked them) still - // parses, leaving the live knobs untouched on apply. The mode is kept with the - // manual knobs (gpuLayers/nCpuMoe are ignored outside Manual mode). A null - // selectedGpuIds is meaningful (all GPUs), so it's distinguished from absent. - // The per-GPU split ratio is deliberately NOT remembered: it's positionally - // bound to the exact GPU set/order and unvalidated, so it would mismatch. - gpuMemoryMode?: "auto" | "manual"; - gpuLayers?: number; - nCpuMoe?: number; - selectedGpuIds?: number[] | null; -} - -// Storage key for a pick's remembered settings, scoped per quant (the VRAM-budget -// knobs differ per quant). An HF repo collapses its GGUF variants into one `id`, -// so fold the variant in. Local .gguf paths are already file-specific; native -// drag-drop files key by display label, so same-named files share an entry. -export function rememberedLoadSettingsKey(selection: { - id: string; - ggufVariant?: string | null; -}): string { - return selection.ggufVariant - ? `${selection.id}::${selection.ggufVariant}` - : selection.id; -} - -function readAll(): Record { - try { - return JSON.parse(localStorage.getItem(KEY) ?? "{}"); - } catch { - return {}; - } -} - -function writeAll(all: Record) { - try { - localStorage.setItem(KEY, JSON.stringify(all)); - } catch { - // Ignore quota / unavailable storage. - } -} - -export function loadRememberedLoadSettings( - key: string, -): RememberedLoadSettings | null { - return readAll()[key] ?? null; -} - -export function saveRememberedLoadSettings( - key: string, - settings: RememberedLoadSettings, -) { - const all = readAll(); - all[key] = settings; - writeAll(all); -} - -export function clearRememberedLoadSettings(key: string) { - const all = readAll(); - if (key in all) { - delete all[key]; - writeAll(all); - } -} diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 7083f02288..b0127b5e40 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -2,10 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { getAuthToken } from "@/features/auth"; -import { - loadRememberedLoadSettings, - rememberedLoadSettingsKey, -} from "@/components/assistant-ui/model-selector/remembered-load-settings"; +import { resolveInitialConfig } from "@/features/model-picker"; import { projectHasSources } from "@/features/rag/api/rag-api"; import { apiUrl } from "@/lib/api-base"; import { parseParamCountB } from "@/lib/model-size"; @@ -46,7 +43,7 @@ import { type PendingImageEditReference, type RagAutoInject, GPU_LAYERS_AUTO, - loadedGpuMemoryFieldsUnlessStaged, + loadedGpuMemoryFields, reconcilePersistedGpuIds, resolveLoadedSpeculativeSettings, resolveSpeculativeSettingsForLoad, @@ -1533,65 +1530,56 @@ async function autoLoadSmallestModel(): Promise<{ return false; } const currentStore = useChatRuntimeStore.getState(); - // Blobs are saved for GGUF picks only (the sheet gates on it), so don't - // let a legacy non-GGUF blob feed a stale context/spec choice into a - // safetensors auto-load. - const remembered = - candidate.kind === "gguf" - ? loadRememberedLoadSettings( - rememberedLoadSettingsKey({ - id: candidate.id, - ggufVariant: candidate.ggufVariant, - }), - ) - : null; + const { config } = resolveInitialConfig(candidate.id, candidate.ggufVariant); const effectiveMaxSeqLength = resolveLoadMaxSeqLength({ modelId: candidate.id, ggufVariant: candidate.ggufVariant, isGguf: candidate.kind === "gguf", - customContextLength: remembered?.contextLength ?? null, + customContextLength: config.customContextLength, ggufContextLength: null, currentCheckpoint: currentStore.params.checkpoint, activeGgufVariant: currentStore.activeGgufVariant, - maxSeqLength: candidate.maxSeqLength, + maxSeqLength: config.maxSeqLength ?? candidate.maxSeqLength, presetSource: currentStore.activePresetSource, }); - // The GPU knobs are per-model, so read them from the same remembered - // settings that fed effectiveMaxSeqLength -- on a background auto-load the - // live store holds session defaults, not the saved Manual mode / layer pin / - // GPU pick. Absent fields fall back like applyRememberedLoadSettings: the - // mode to the store (a persisted standing preference), the per-model knobs to - // their defaults. The saved GPU pick is reconciled against the GPUs present - // now, like the interactive restore. + // The GPU knobs are per-model, so read them from the same per-model config + // that fed effectiveMaxSeqLength -- on a background auto-load the live store + // holds session defaults, not the saved Manual mode / layer pin / GPU pick. + // Absent fields fall back like the interactive restore: the mode to the store + // (a persisted standing preference), the per-model knobs to their defaults. + // The saved GPU pick is reconciled against the GPUs present now. const effectiveGpuMemoryMode = - remembered?.gpuMemoryMode ?? currentStore.gpuMemoryMode; - const effectiveGpuLayers = remembered?.gpuLayers ?? GPU_LAYERS_AUTO; - const effectiveNCpuMoe = remembered?.nCpuMoe ?? 0; - if (remembered?.selectedGpuIds != null) { + config.gpuMemoryMode ?? currentStore.gpuMemoryMode; + const effectiveGpuLayers = config.gpuLayers ?? GPU_LAYERS_AUTO; + const effectiveNCpuMoe = config.nCpuMoe ?? 0; + if (config.selectedGpuIds != null) { // Warm the device cache first: on a cold cache the reconcile passes the // saved pick through unvalidated, and a stale cross-host pick then fails // the load with the picker hidden. await ensureGpuDeviceCache(); } const effectiveGpuIds = - remembered?.selectedGpuIds !== undefined - ? reconcilePersistedGpuIds(remembered.selectedGpuIds) + config.selectedGpuIds !== undefined + ? reconcilePersistedGpuIds(config.selectedGpuIds) : null; // Under Manual GPU memory + Auto layers, llama.cpp's --fit owns context // sizing, so send 0 (or the pinned length). GGUF-only; a no-op otherwise. - // The context pin is per-model too, so it comes from remembered settings, - // not the live store. + // The context pin is per-model too, so it comes from the saved config, not + // the live store. const fitMaxSeqLength = resolveFitMaxSeqLength( candidate.kind === "gguf", effectiveGpuMemoryMode, effectiveGpuLayers, - remembered?.contextLength ?? null, + config.customContextLength ?? null, effectiveMaxSeqLength, ); const effectiveSpeculativeType = - remembered?.speculativeType ?? specSettings.speculativeType; + config.speculativeType ?? specSettings.speculativeType; const effectiveSpecDraftNMax = - remembered?.specDraftNMax ?? specSettings.specDraftNMax; + config.specDraftNMax ?? specSettings.specDraftNMax; + const effectiveChatTemplateOverride = config.chatTemplateOverride?.trim() + ? config.chatTemplateOverride + : null; if ( !(await canAutoLoad({ model_path: candidate.id, @@ -1621,10 +1609,11 @@ async function autoLoadSmallestModel(): Promise<{ is_lora: false, gguf_variant: candidate.ggufVariant, trust_remote_code: trustRemoteCode, - cache_type_kv: remembered?.kvCacheDtype ?? null, + chat_template_override: effectiveChatTemplateOverride, + cache_type_kv: config.kvCacheDtype, speculative_type: effectiveSpeculativeType, spec_draft_n_max: effectiveSpecDraftNMax, - tensor_parallel: remembered?.tensorParallel ?? false, + tensor_parallel: config.tensorParallel, // GGUF-only: the safetensors fallback loads via HF auto-placement (no // explicit pins). The split ratio is deliberately never remembered // (positionally bound to an exact GPU set), so auto-load leaves llama.cpp's @@ -1638,7 +1627,12 @@ async function autoLoadSmallestModel(): Promise<{ } : {}), }); - saveSpeculativeType(effectiveSpeculativeType); + // Only persist the global preference when the value came from the global + // settings. A per-model config's choice must stay load-local, or autoloading + // a remembered model on startup would rewrite the global default. + if (config.speculativeType == null) { + saveSpeculativeType(effectiveSpeculativeType); + } // Self-gates on is_gguf (skips diffusion), so persists only for a real GGUF load. persistGpuMemoryModeOnLoad(loadResp, effectiveGpuMemoryMode); useChatRuntimeStore @@ -1650,6 +1644,9 @@ async function autoLoadSmallestModel(): Promise<{ ); store.setParams({ ...store.params, + ...(candidate.kind === "gguf" + ? {} + : { maxSeqLength: effectiveMaxSeqLength }), maxTokens: candidate.kind === "gguf" ? loadResp.context_length ?? 131072 @@ -1676,7 +1673,7 @@ async function autoLoadSmallestModel(): Promise<{ const keepCustomCtx = resolveManualAutoCtxPin( effectiveGpuMemoryMode, effectiveGpuLayers, - remembered?.contextLength ?? null, + config.customContextLength ?? null, ); useChatRuntimeStore.setState({ ggufContextLength: loadResp.context_length ?? 131072, @@ -1694,13 +1691,14 @@ async function autoLoadSmallestModel(): Promise<{ loadedKvCacheDtype: loadResp.cache_type_kv ?? null, tensorParallel: loadResp.tensor_parallel ?? false, loadedTensorParallel: loadResp.tensor_parallel ?? false, - ...loadedGpuMemoryFieldsUnlessStaged(loadResp, { - customContextLength: keepCustomCtx, - }), + ...loadedGpuMemoryFields(loadResp), loadedCustomContextLength: keepCustomCtx, defaultChatTemplate: loadResp.chat_template ?? null, - chatTemplateOverride: null, - loadedChatTemplateOverride: null, + chatTemplateOverride: effectiveChatTemplateOverride, + loadedChatTemplateOverride: effectiveChatTemplateOverride, + // Retain the saved requested context so re-saving the config keeps the + // override; null stays null (auto/VRAM-fit). + customContextLength: config.customContextLength, loadedIsMultimodal: isMultimodalResponse(loadResp), loadedIsDiffusion: loadResp.is_diffusion ?? false, ...resolveLoadedSpeculativeSettings(loadResp), @@ -1720,10 +1718,11 @@ async function autoLoadSmallestModel(): Promise<{ loadedTensorParallel: loadResp.tensor_parallel ?? false, // Non-GGUF response: clears any stale GPU baseline a prior manual-GPU // GGUF load left, matching the interactive/status sibling load paths. - ...loadedGpuMemoryFieldsUnlessStaged(loadResp), + ...loadedGpuMemoryFields(loadResp), defaultChatTemplate: loadResp.chat_template ?? null, - chatTemplateOverride: null, - loadedChatTemplateOverride: null, + chatTemplateOverride: effectiveChatTemplateOverride, + loadedChatTemplateOverride: effectiveChatTemplateOverride, + customContextLength: null, ...resolveLoadedSpeculativeSettings(loadResp), loadedIsMultimodal: isMultimodalResponse(loadResp), loadedIsDiffusion: loadResp.is_diffusion ?? false, @@ -1988,7 +1987,7 @@ async function autoLoadSmallestModel(): Promise<{ loadedKvCacheDtype: loadResp.cache_type_kv ?? null, tensorParallel: loadResp.tensor_parallel ?? false, loadedTensorParallel: loadResp.tensor_parallel ?? false, - ...loadedGpuMemoryFieldsUnlessStaged(loadResp), + ...loadedGpuMemoryFields(loadResp), // Drives the GPU Memory controls' diffusion gate; set alongside the // GPU fields on every load path so the gate can't read stale. loadedIsDiffusion: loadResp.is_diffusion ?? false, diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index 631474c39a..de3e5e370c 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -377,14 +377,33 @@ export async function listCachedModels( return data.cached; } -export async function deleteCachedModel( +export interface CachedModelPath { + path: string; + is_dir: boolean; +} + +/** Absolute on-disk path of a cached repo or one of its GGUF variants. */ +export async function getCachedModelPath( + repoId: string, + variant?: string, +): Promise { + const params = new URLSearchParams({ repo_id: repoId }); + if (variant) params.set("variant", variant); + const response = await authFetch( + `/api/models/cached-model-path?${params.toString()}`, + ); + return parseJsonOrThrow(response); +} + +/** Reveal a cached repo (or one GGUF variant's file) in the OS file manager. */ +export async function revealCachedModel( repoId: string, variant?: string, ): Promise { const payload: Record = { repo_id: repoId }; if (variant) payload.variant = variant; - const response = await authFetch("/api/models/delete-cached", { - method: "DELETE", + const response = await authFetch("/api/models/reveal-cached-model", { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 217eaf8b6d..ef018445e0 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -2,16 +2,19 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { + applyModelLoadConfigToRuntime, + currentRuntimePerModelConfig, type DeletedModelRef, type ExternalModelOption, type LoraModelOption, type ModelOption, ModelSelector, -} from "@/components/assistant-ui/model-selector"; -import { - loadRememberedLoadSettings, - rememberedLoadSettingsKey, -} from "@/components/assistant-ui/model-selector/remembered-load-settings"; + type ModelSelectorChangeMeta, + type PerModelConfig, + resolveInitialConfig, + SidebarModelConfig, + useActiveModelConfig, +} from "@/features/model-picker"; import { ProjectComposer, Thread } from "@/components/assistant-ui/thread"; import { CopyableErrorChip } from "@/components/ui/copyable-error-chip"; import { @@ -27,10 +30,10 @@ import { } from "@/components/ui/resizable"; import { useSidebar } from "@/components/ui/sidebar"; import { Tooltip, TooltipContent } from "@/components/ui/tooltip"; -import { useLatestRef } from "@/features/hub/hooks/use-latest-ref"; import { DOWNLOAD_KIND, downloadManager, + useRepoDownload, } from "@/features/hub/download-manager"; import { type NativeIntent, @@ -93,7 +96,6 @@ import { renameChatItem, useChatSidebarItems, } from "./hooks/use-chat-sidebar-items"; -import { useStagedModelPreparation } from "./hooks/use-staged-model-preparation"; import { clearTrainingCompareHandoff, getTrainingCompareHandoff, @@ -128,10 +130,8 @@ import { hasGgufSource, isDownloadableHubRepo, loadOptionalBool, - pendingSelectionMatches, useChatRuntimeStore, } from "./stores/chat-runtime-store"; -import type { PendingModelSelection } from "./stores/chat-runtime-store"; import { useChatPreferencesStore } from "./stores/chat-preferences-store"; import { useExternalProvidersStore } from "./stores/external-providers-store"; import { buildChatTourSteps } from "./tour"; @@ -385,6 +385,7 @@ type CompareModelSelection = { id: string; isLora: boolean; ggufVariant?: string; + config?: PerModelConfig; }; function modelMatchesDeleted( @@ -645,6 +646,8 @@ function GeneralCompareHeader({ loraModels, externalModels, value, + selectedConfig, + selectedGgufVariant, onValueChange, onFoldersChange, onModelsChange, @@ -655,9 +658,11 @@ function GeneralCompareHeader({ loraModels: LoraModelOption[]; externalModels: ExternalModelOption[]; value: string; + selectedConfig?: PerModelConfig | null; + selectedGgufVariant?: string | null; onValueChange: ( id: string, - meta: { isLora: boolean; ggufVariant?: string }, + meta: ModelSelectorChangeMeta, ) => void; onFoldersChange?: () => void; onModelsChange?: (deletedModel?: DeletedModelRef) => void; @@ -684,6 +689,8 @@ function GeneralCompareHeader({ loraModels={loraModels} externalModels={externalModels} value={value} + selectedConfig={selectedConfig} + selectedGgufVariant={selectedGgufVariant} onValueChange={onValueChange} onFoldersChange={onFoldersChange} onModelsChange={onModelsChange} @@ -811,11 +818,14 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ loraModels={loraModels} externalModels={externalModels} value={model1.id} + selectedConfig={model1.config} + selectedGgufVariant={model1.ggufVariant} onValueChange={(id, meta) => setModel1({ id, isLora: meta.isLora, ggufVariant: meta.ggufVariant, + config: meta.config, }) } onFoldersChange={onFoldersChange} @@ -838,11 +848,14 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ loraModels={loraModels} externalModels={externalModels} value={model2.id} + selectedConfig={model2.config} + selectedGgufVariant={model2.ggufVariant} onValueChange={(id, meta) => setModel2({ id, isLora: meta.isLora, ggufVariant: meta.ggufVariant, + config: meta.config, }) } onFoldersChange={onFoldersChange} @@ -1236,6 +1249,13 @@ export function validateChatSearch(search: Record): ChatSearch }; } +type PendingHubAutoLoad = { + selection: SelectedModelInput; + contextKey: string; + originCheckpoint: string; + originGgufVariant: string | null; +}; + // `search` comes from RootLayout (not useSearch) so ChatPage stays mounted off-route // (keeping an in-flight generation alive), frozen to the last /chat search. `active` // is false off-route: close body-portaled surfaces and stop route-specific listeners @@ -1248,30 +1268,6 @@ export function ChatPage({ const settingsOpen = useChatRuntimeStore((s) => s.settingsPanelOpen); const setSettingsOpen = useChatRuntimeStore((s) => s.setSettingsPanelOpen); - // Deferred-load staging: downloads a staged GGUF (if needed) and reads its - // header context so the sheet can show the context slider before the load. - // autoLoad picks instead load the cached file as soon as the download ends; - // selectModel is defined below, so the load runs through a ref. - const autoLoadStagedRef = useRef< - ((pending: PendingModelSelection) => void) | null - >(null); - const stagedDownload = useStagedModelPreparation({ - onAutoLoad: (pending) => autoLoadStagedRef.current?.(pending), - }); - // Abandon a staged pick: the store action cancels its in-flight download and - // reverts the edited knobs, so nothing lingers after the user walks away. - const abandonStaged = useCallback(() => { - useChatRuntimeStore.getState().abandonStagedModel(); - }, []); - // Detach a staged pick on navigation without cancelling its download: the - // transfer keeps running in the manager and lands in cache, like Hub. - const detachStaged = useCallback(() => { - useChatRuntimeStore.getState().abandonStagedModel({ keepDownload: true }); - }, []); - // Tracks whether the chat page is still mounted, so a staged-load failure that - // resolves after the user left chat doesn't resurrect the abandoned pick. - const mountedRef = useRef(true); - useEffect(() => () => void (mountedRef.current = false), []); const incognito = useChatRuntimeStore((s) => s.incognito); const setIncognito = useChatRuntimeStore((s) => s.setIncognito); const incognitoLabel = incognito @@ -1363,6 +1359,9 @@ export function ChatPage({ const ggufContextLength = useChatRuntimeStore( (state) => state.ggufContextLength, ); + const ggufNativeContextLength = useChatRuntimeStore( + (state) => state.ggufNativeContextLength, + ); const contextUsage = useChatRuntimeStore((state) => state.contextUsage); const modelsFromStore = useChatRuntimeStore((state) => state.models); const lorasFromStore = useChatRuntimeStore((state) => state.loras); @@ -1440,39 +1439,37 @@ export function ChatPage({ refreshRef.current = refresh; selectModelRef.current = selectModel; }, [refresh, selectModel]); - // Load a cached autoLoad pick once its download finishes. The sheet was never - // opened, so on a load failure just drop the orphaned staged knobs. The knobs - // were already seeded on stage, so keepSpeculative only when a config was - // saved -- otherwise the standing speculative preference should win. - autoLoadStagedRef.current = (pending) => { - // Blobs are saved for GGUF picks only (the sheet gates on it), so don't - // let a legacy non-GGUF blob claim a seeded config here. - const remembered = hasGgufSource(pending) - ? loadRememberedLoadSettings(rememberedLoadSettingsKey(pending)) - : null; - void selectModel({ - ...pending, - isDownloaded: true, - forceReload: true, - keepSpeculative: remembered != null, - throwOnError: true, - }).catch(() => { - const store = useChatRuntimeStore.getState(); - // selectModel only clears pendingSelection on success, so a failed - // auto-load leaves our staged pick (and its edited load knobs) behind. - // Abandon it when it is still the active stage; otherwise just revert the - // settings if the stage was already cleared by something else. - if (pendingSelectionMatches(store.pendingSelection, pending)) { - store.abandonStagedModel(); - } else if (!store.pendingSelection) { - store.resetModelSettingsToLoaded(); - } - }); - }; + const rememberedConfigFor = useCallback( + (selection: { + id: string; + ggufVariant?: string | null; + source?: string; + }) => { + if (selection.source === "external") return null; + const resolved = resolveInitialConfig(selection.id, selection.ggufVariant); + return resolved.remembered ? resolved.config : null; + }, + [], + ); const isExternalModel = useMemo( () => isExternalModelId(inferenceParams.checkpoint), [inferenceParams.checkpoint], ); + const { + checkpoint: runtimeCheckpoint, + isGguf: runtimeModelIsGguf, + config: activeModelConfig, + } = useActiveModelConfig(); + const activeModelIsGguf = + runtimeCheckpoint != null && !isExternalModel && runtimeModelIsGguf; + const activeModelIsLora = useMemo(() => { + const checkpoint = inferenceParams.checkpoint; + if (!checkpoint || isExternalModel) return false; + const model = modelsFromStore.find((entry) => entry.id === checkpoint); + if (model) return model.isLora; + const lora = lorasFromStore.find((entry) => entry.id === checkpoint); + return lora?.exportType === "lora"; + }, [inferenceParams.checkpoint, isExternalModel, modelsFromStore, lorasFromStore]); const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled); const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle); const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort); @@ -1783,75 +1780,21 @@ export function ChatPage({ closeArtifactSurface(); }, [activeThreadId, closeArtifactSurface, selectedArtifact, view]); - // Abandon a staged (not-yet-loaded) pick when the chat context actually - // changes — switching threads, leaving single view, or starting a new chat / - // project — so a stale Load button can't resurface in a different context. - // New Chat keeps activeThreadId null and only bumps the `new` search nonce, so - // the key includes the route identity, not just the thread. Mirrors the - // incognito reset pattern. (Route exit is handled in __root.tsx, which runs - // after this unmounts.) Clear only on a real change, never on mount: staging - // from the Hub sets pendingSelection then navigates here, and clearing on - // mount would wipe it. Comparing the previous context (rather than a first-run - // flag) is also safe under StrictMode's double-invoke and component remounts. - const chatContextKey = `${view.mode}|${activeThreadId ?? ""}|${search.new ?? ""}|${search.project ?? ""}`; - const chatContextKeyRef = useLatestRef(chatContextKey); - const prevChatContextRef = useRef(null); - useEffect(() => { - const prev = prevChatContextRef.current; - prevChatContextRef.current = chatContextKey; - if (prev === null || prev === chatContextKey) return; - detachStaged(); - }, [chatContextKey, detachStaged]); - const hasActiveModel = Boolean(inferenceParams.checkpoint); - // Load immediately, or — when "Load on selection" is off — stage the pick so - // its load options can be set first. Shared by the main selector, native - // drag-drop/picker, and the dropped-file chip (the Hub stages via the store). + const chatContextKey = `${view.mode}|${activeThreadId ?? ""}|${search.new ?? ""}|${search.project ?? ""}`; + const [pendingHubAutoLoad, setPendingHubAutoLoad] = + useState(null); const stageOrLoad = useCallback( async (selection: SelectedModelInput) => { const store = useChatRuntimeStore.getState(); - // An un-cached HF repo (GGUF variant or a full non-GGUF snapshot) downloads - // through the manager first (global indicator), then auto-loads. Everything - // else -- cached picks, local/native files, LoRA, external -- loads now. const wantManagerDownload = isDownloadableHubRepo(selection) && !selection.isDownloaded; - if ( - (!hasGgufSource(selection) && !wantManagerDownload) || - (store.loadOnSelection && selection.isDownloaded) - ) { - // Detach any staged pick first so its edited knobs (e.g. a custom - // context length) don't leak into this immediate load -- resolveLoad - // reads customContextLength before checking the target is GGUF. Detach - // (not abandon) keeps its download running. - detachStaged(); - // Load-on-selection skips the sheet, so seed the saved knobs here the - // way the sheet's restore effect would; the switch would otherwise reset - // the remembered speculative choice (keepSpeculative below prevents it). - const remembered = hasGgufSource(selection) - ? loadRememberedLoadSettings(rememberedLoadSettingsKey(selection)) - : null; - if (remembered) store.applyRememberedLoadSettings(remembered); - await selectModel( - remembered ? { ...selection, keepSpeculative: true } : selection, - ); - return; - } - // Loads can't queue behind each other, but a download is independent: if - // the pick needs downloading, start it in the manager so it runs alongside - // the load. Nothing to download (already on device) just waits. if (store.modelLoading) { - // Both an uncached non-GGUF snapshot (wantManagerDownload) and an - // uncached remote GGUF quant download through the manager, so either can - // run in the background while another model loads. wantManagerDownload - // excludes GGUF by design, so the GGUF case is checked separately. const wantBackgroundDownload = wantManagerDownload || (selection.source === "hub" && hasGgufSource(selection) && !selection.isDownloaded); - // The model currently loading already downloads as part of its own load - // (the /load flow fetches before setting the checkpoint), so re-picking - // it must not kick off a second transfer against the same cache. const isLoadingThisPick = !!loadingModel && normalizeModelRef(loadingModel.id) === @@ -1862,11 +1805,6 @@ export function ChatPage({ description: "It's downloading as part of the load in progress.", }); } else if (wantBackgroundDownload) { - // Only claim the download started once a job is actually created. A - // transport conflict records state that is only resolvable from the - // Hub download card, so point the user there instead of showing a - // success toast for a transfer that never began; "busy" and "error" - // already surface their own toasts. const outcome = await downloadManager.requestStart({ kind: DOWNLOAD_KIND.MODEL, repoId: selection.id, @@ -1883,6 +1821,11 @@ export function ChatPage({ description: "An earlier partial download used a different transport. Open the Hub tab to resume or restart it.", }); + } else if (outcome === "busy") { + toast.info("Download already in progress", { + description: + "Another download for this model is still running. Reselect it once that finishes to load it.", + }); } } else { toast.info("Another model is already loading", { @@ -1891,23 +1834,128 @@ export function ChatPage({ } return; } - // Detach the prior staged pick (keeping its download) before rebinding, so - // a second pick downloads alongside the first instead of cancelling it. - detachStaged(); - store.stageModel({ - id: selection.id, - isLora: selection.isLora, - ggufVariant: selection.ggufVariant, - isDownloaded: selection.isDownloaded, - expectedBytes: selection.expectedBytes, - nativePathToken: selection.nativePathToken, - isGguf: selection.isGguf, - isHubRepo: wantManagerDownload || undefined, - autoLoad: store.loadOnSelection, + const wantManagerStage = + wantManagerDownload || + (selection.source === "hub" && + hasGgufSource(selection) && + !selection.isDownloaded); + if (wantManagerStage) { + setPendingHubAutoLoad((current) => + current && + current.selection.id === selection.id && + (current.selection.ggufVariant ?? null) === + (selection.ggufVariant ?? null) && + current.contextKey === chatContextKey && + current.originCheckpoint === store.params.checkpoint && + current.originGgufVariant === store.activeGgufVariant + ? current + : { + selection, + contextKey: chatContextKey, + originCheckpoint: store.params.checkpoint, + originGgufVariant: store.activeGgufVariant, + }, + ); + return; + } + setPendingHubAutoLoad(null); + const previousConfig = currentRuntimePerModelConfig({ + includeMaxSeqLength: true, + }); + const hasAppliedConfig = applyModelLoadConfigToRuntime( + selection.config ?? rememberedConfigFor(selection), + ); + await selectModel({ + ...selection, + ...(hasAppliedConfig ? { keepSpeculative: true } : {}), + previousConfig, }); }, - [detachStaged, selectModel, loadingModel], + [selectModel, loadingModel, rememberedConfigFor, chatContextKey], ); + useRepoDownload({ + kind: DOWNLOAD_KIND.MODEL, + repoId: pendingHubAutoLoad?.selection.id ?? "__hub_autoload_idle__", + activeVariant: pendingHubAutoLoad?.selection.ggufVariant ?? null, + onComplete: (variant) => { + const pending = pendingHubAutoLoad; + if ( + !pending || + (pending.selection.ggufVariant ?? null) !== (variant ?? null) + ) { + return; + } + setPendingHubAutoLoad(null); + const store = useChatRuntimeStore.getState(); + if ( + !active || + pending.contextKey !== chatContextKey || + normalizeModelRef(pending.originCheckpoint) !== + normalizeModelRef(store.params.checkpoint) || + pending.originGgufVariant !== store.activeGgufVariant + ) { + return; + } + void stageOrLoad({ ...pending.selection, isDownloaded: true }); + }, + onError: (variant) => { + if ( + pendingHubAutoLoad && + (pendingHubAutoLoad.selection.ggufVariant ?? null) === (variant ?? null) + ) { + setPendingHubAutoLoad(null); + } + }, + onCancelled: (variant) => { + if ( + pendingHubAutoLoad && + (pendingHubAutoLoad.selection.ggufVariant ?? null) === (variant ?? null) + ) { + setPendingHubAutoLoad(null); + } + }, + }); + useEffect(() => { + const pending = pendingHubAutoLoad; + if (!pending) return; + let active = true; + void (async () => { + const outcome = await downloadManager.requestStart({ + kind: DOWNLOAD_KIND.MODEL, + repoId: pending.selection.id, + variant: pending.selection.ggufVariant ?? null, + expectedBytes: pending.selection.expectedBytes ?? 0, + }); + if (!active) return; + if (outcome === "started") { + toast.info("Downloading model", { + description: "It'll load automatically once the download finishes.", + }); + return; + } + if (outcome === "conflict") { + // Keep pendingHubAutoLoad bound so this surface's cleanup does not wipe + // the conflict just recorded by requestStart (which the toast points the + // user to); resolving it from the Hub completes the download and this + // surface's onComplete auto-loads, mirroring the "started" branch. + toast.info("Resume this download from the Hub", { + description: + "An earlier partial download used a different transport. Open the Hub tab to resume or restart it.", + }); + return; + } + if (outcome === "busy") { + toast.info("Download already in progress", { + description: + "Another download for this model is still running. Reselect it once that finishes to load it.", + }); + } + setPendingHubAutoLoad((current) => (current === pending ? null : current)); + })(); + return () => { + active = false; + }; + }, [pendingHubAutoLoad]); const loadNativeModelIntent = useCallback( async (intent: NativeIntent, loadingDescription: string) => { const label = @@ -1915,6 +1963,7 @@ export function ChatPage({ await stageOrLoad({ id: label, nativePathToken: intent.path.token, + nativePathExpiresAtMs: intent.path.expiresAtMs ?? null, isDownloaded: true, loadingDescription, forceReload: true, @@ -1965,28 +2014,20 @@ export function ChatPage({ const handleCheckpointChange = useCallback( ( value: string, - meta?: { - source?: string; - isLora: boolean; - ggufVariant?: string; - isDownloaded?: boolean; - expectedBytes?: number; - isGguf?: boolean; - }, + meta?: ModelSelectorChangeMeta, ) => { const store = useChatRuntimeStore.getState(); const currentCheckpoint = store.params.checkpoint; const currentVariant = store.activeGgufVariant; - if ( - !value || - (value === currentCheckpoint && - (meta?.ggufVariant ?? null) === (currentVariant ?? null)) - ) + if (!value) return; + setPendingHubAutoLoad(null); + const isSameLoadedModel = + value === currentCheckpoint && + (meta?.ggufVariant ?? null) === (currentVariant ?? null); + if (isSameLoadedModel && !meta?.forceReload) { return; + } if (meta?.source === "external" || isExternalModelId(value)) { - // Switching to an external model abandons any staged local pick: cancel - // its download too (setCheckpoint below only clears the pending + knobs). - abandonStaged(); const selectedExternal = parseExternalModelId(value); const selectedProvider = selectedExternal ? externalProvidersForChat.find( @@ -2087,6 +2128,7 @@ export function ChatPage({ ggufMaxContextLength: null, ggufNativeContextLength: null, activeNativePathToken: null, + activeNativePathExpiresAtMs: null, // Clear previous-model counters, else the relaxed external-provider // render gate shows stale stats until the next completion. contextUsage: null, @@ -2158,19 +2200,18 @@ export function ChatPage({ source: meta?.source, isLora: meta?.isLora, ggufVariant: meta?.ggufVariant, - isDownloaded: meta?.isDownloaded, + isDownloaded: meta?.isDownloaded || isSameLoadedModel, expectedBytes: meta?.expectedBytes, isGguf: meta?.isGguf, + config: meta?.config, + nativePathToken: meta?.nativePathToken, + nativePathExpiresAtMs: meta?.nativePathExpiresAtMs, + forceReload: isSameLoadedModel || undefined, }; - // "Load on selection" off: stage the model and open settings so its - // load knobs (tensor parallel, context length…) can be set, then it - // loads once via the sheet's Load button. The currently loaded model - // stays put until the user commits. await stageOrLoad(selection); })(); }, [ - abandonStaged, activeThreadId, externalProvidersForChat, modelsFromStore, @@ -2178,6 +2219,45 @@ export function ChatPage({ view, ], ); + const handleReloadActiveModel = useCallback( + (config: PerModelConfig) => { + const checkpoint = inferenceParams.checkpoint; + if (!checkpoint) return; + const runtime = useChatRuntimeStore.getState(); + const nativeToken = runtime.activeNativePathToken; + const nativeExpiry = runtime.activeNativePathExpiresAtMs; + // A file-picked GGUF is reachable only via its native path token, which + // the desktop host prunes after a TTL. Reusing an expired token makes the + // reload fail with an opaque error, so prompt the user to re-select the + // file instead. + if (nativeToken && nativeExpiry != null && Date.now() >= nativeExpiry) { + toast.error("This local model file's access has expired.", { + description: "Re-select the model file to reload it.", + }); + return; + } + handleCheckpointChange(checkpoint, { + source: "local", + isLora: activeModelIsLora, + ggufVariant: activeGgufVariant ?? undefined, + // Without the native token the reload validates the display label as a + // repo and fails. + nativePathToken: nativeToken ?? undefined, + nativePathExpiresAtMs: nativeExpiry, + isGguf: activeModelIsGguf, + isDownloaded: true, + config, + forceReload: true, + }); + }, + [ + inferenceParams.checkpoint, + activeGgufVariant, + activeModelIsLora, + activeModelIsGguf, + handleCheckpointChange, + ], + ); const handleEject = useCallback(() => { void (async () => { if (await ejectModel()) { @@ -2446,12 +2526,27 @@ export function ChatPage({ const state = useChatRuntimeStore.getState(); const targetLora = pickBestLoraForBase(state.loras, handoff.baseModel); + const selectWithConfig = async ( + selection: Pick, + ) => { + const previousConfig = currentRuntimePerModelConfig({ + includeMaxSeqLength: true, + }); + const hasAppliedConfig = applyModelLoadConfigToRuntime( + rememberedConfigFor(selection), + ); + await selectModelRef.current({ + ...selection, + ...(hasAppliedConfig ? { keepSpeculative: true } : {}), + previousConfig, + }); + }; if (targetLora) { console.info("[chat-handoff] loading lora", { id: targetLora.id, baseModel: targetLora.baseModel, }); - await selectModelRef.current({ id: targetLora.id, isLora: true }); + await selectWithConfig({ id: targetLora.id, isLora: true }); if (canceled) return; useChatRuntimeStore.getState().setActiveThreadId(null); useChatRuntimeStore.getState().setContextUsage(null); @@ -2468,10 +2563,7 @@ export function ChatPage({ console.info("[chat-handoff] no lora match, loading base", { id: handoff.baseModel, }); - await selectModelRef.current({ - id: handoff.baseModel, - isLora: false, - }); + await selectWithConfig({ id: handoff.baseModel, isLora: false }); if (canceled) return; } else { console.warn("[chat-handoff] no lora/base match found", { @@ -2491,7 +2583,7 @@ export function ChatPage({ return () => { canceled = true; }; - }, [active, navigate]); + }, [active, navigate, rememberedConfigFor]); const tourSteps = useMemo( () => @@ -2580,6 +2672,8 @@ export function ChatPage({ externalModels={externalModels} value={inferenceParams.checkpoint} activeGgufVariant={activeGgufVariant} + activeModelConfig={activeModelConfig} + activeGgufContextLength={ggufContextLength} onValueChange={handleCheckpointChange} onEject={handleEject} onFoldersChange={refreshLocalModels} @@ -2633,7 +2727,12 @@ export function ChatPage({ stageOrLoad(selection)} + onLoad={() => + loadNativeModelIntent( + pendingNativeModelIntent, + "Loading selected local GGUF model.", + ) + } /> ) : null} {loadingModel && loadToastDismissed ? ( @@ -2790,13 +2889,22 @@ export function ChatPage({ open={active && settingsOpen} onOpenChange={(open) => { setSettingsOpen(open); - // Closing the sheet abandons a staged (not-yet-loaded) pick: cancel its - // download and revert the staged knobs so nothing lingers as a dirty - // edit (or a background download) on the loaded model. - if (!open) abandonStaged(); }} params={inferenceParams} onParamsChange={setInferenceParams} + modelConfig={ + view.mode !== "compare" && activeModelConfig && !modelLoading ? ( + + ) : null + } isExternalModel={isExternalModel} providerCapabilities={activeProviderCapabilities} activeExternalProvider={activeExternalProvider} @@ -2808,67 +2916,6 @@ export function ChatPage({ ); }} externalProviderType={activeExternalProviderType} - loadingModel={loadingModel} - onReloadModel={() => { - const state = useChatRuntimeStore.getState(); - if (state.params.checkpoint) { - selectModel({ - id: state.params.checkpoint, - ggufVariant: state.activeGgufVariant ?? undefined, - // A native (drag-drop / picked) GGUF's checkpoint is only a display - // label, so the reload needs its path token to re-mint a lease -- - // else applying the now-exposed GPU/context controls can't resolve - // the file. Null for non-native loads, which reload by id as before. - nativePathToken: state.activeNativePathToken ?? undefined, - forceReload: true, - isDownloaded: true, - loadingDescription: "Reloading with updated chat template.", - }); - } - }} - onLoadPendingModel={() => { - const pending = useChatRuntimeStore.getState().pendingSelection; - if (!pending) return; - const keyAtLoad = chatContextKey; - // forceReload: the staged model isn't loaded yet, so bypass the - // same-checkpoint dedupe. keepSpeculative: honor the speculative mode - // set on the sidebar. - void selectModel({ - ...pending, - forceReload: true, - keepSpeculative: true, - throwOnError: true, - }).catch(() => { - // Recoverable failure (expired token, gated repo, OOM…): the pick is - // cleared only on success, so it normally stays staged with edited - // knobs intact — nothing to restore. - const store = useChatRuntimeStore.getState(); - // Still staged (this pick, or a newer one queued meanwhile): leave it. - if (store.pendingSelection) return; - // Cleared mid-load (sheet closed / switched chats). Re-stage only if - // the staged-load is still wanted: same chat context, sheet still - // open, page still mounted. - const stillWanted = - mountedRef.current && - store.settingsPanelOpen && - chatContextKeyRef.current === keyAtLoad; - if (stillWanted) { - store.setPendingSelection(pending); - } else { - // Abandoned (closed the sheet / switched chats / left chat): drop - // the orphaned staged knob edits so they don't linger as dirty - // settings over the loaded model. - store.resetModelSettingsToLoaded(); - } - }); - }} - stagedDownloadFraction={stagedDownload.progress?.fraction ?? null} - onCancelStagedDownload={() => - stagedDownload.cancelDownload( - useChatRuntimeStore.getState().pendingSelection?.ggufVariant ?? - null, - ) - } /> diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index bd22cc4f55..d4f154882c 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -1,19 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -import { - Alert, - AlertDescription, - AlertTitle, -} from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - clearRememberedLoadSettings, - loadRememberedLoadSettings, - rememberedLoadSettingsKey, - saveRememberedLoadSettings, -} from "@/components/assistant-ui/model-selector/remembered-load-settings"; import { Dialog, DialogContent, @@ -29,7 +17,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; +import { InfoHint } from "@/components/ui/info-hint"; import { InputGroup, InputGroupAddon, @@ -50,27 +38,22 @@ import { SheetTitle, } from "@/components/ui/sheet"; import { Slider } from "@/components/ui/slider"; -import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; -import { InfoHint } from "@/components/ui/info-hint"; import { Tooltip, TooltipContent } from "@/components/ui/tooltip"; -import { useGpuDevices } from "@/hooks/use-gpu-info"; -import { useIsMobile } from "@/hooks/use-mobile"; +import { NumericValueInput, snapToStep } from "@/features/model-picker"; +import { RetrievalSettingsSection } from "@/features/rag"; import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check"; -import { cn } from "@/lib/utils"; -import { - ArrowTurnBackwardIcon, - Edit03Icon, - LayoutAlignRightIcon, -} from "@hugeicons/core-free-icons"; +import { useIsMobile } from "@/hooks/use-mobile"; import { ChevronDownStandardIcon } from "@/lib/chevron-icons"; +import { toast } from "@/lib/toast"; +import { cn } from "@/lib/utils"; +import { Edit03Icon, LayoutAlignRightIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Braces, ChevronDown, ExternalLink } from "lucide-react"; import { Tooltip as TooltipPrimitive } from "radix-ui"; import { Fragment, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "@/lib/toast"; import { OpenAICodeExecSection } from "./components/openai-code-exec-section"; import { PermissionModeDropdown } from "./permission-mode-select"; import { resyncInferenceStatusAfterServerModelChange } from "./hooks/use-chat-model-runtime"; @@ -78,8 +61,8 @@ import { type ExternalProviderConfig, getExternalProviderApiKey, parseExternalModelId, - supportsProviderPromptCaching, supportsProviderPromptCacheTtl, + supportsProviderPromptCaching, } from "./external-providers"; import { BUILTIN_PRESETS, @@ -99,15 +82,7 @@ import { providerSupportsBuiltinCodeExecution, providerSupportsFastMode, } from "./provider-capabilities"; -import { - GPU_LAYERS_AUTO, - distributeByWeight, - isPendingGguf, - pendingSelectionMatches, - rebalanceSplit, - useChatRuntimeStore, -} from "./stores/chat-runtime-store"; -import { RetrievalSettingsSection } from "@/features/rag/components/retrieval-settings-section"; +import { useChatRuntimeStore } from "./stores/chat-runtime-store"; import type { InferenceParams } from "./types/runtime"; export { defaultInferenceParams, type Preset } from "./presets/preset-policy"; @@ -130,7 +105,7 @@ function getPromptVariablesError(raw: string): string | null { return null; } } catch { - return "Use valid JSON, for example { \"env\": \"staging\" }."; + return 'Use valid JSON, for example { "env": "staging" }.'; } return "Variables must be a JSON object."; } @@ -139,112 +114,7 @@ function hasPromptVariableSyntax(prompt: string): boolean { return PROMPT_VARIABLE_PATTERN.test(prompt); } -/** - * Editable numeric value display, shared by every slider value and the Context - * Length input. An that looks like text (shows `displayValue ?? value`, - * so "Off"/"Max" labels render) until focus, when it swaps to the raw number, - * selects it, and accepts free text. Commits on blur/Enter, reverts on Escape. - * Clamping happens on commit so typing intermediate values isn't fought. - */ -function snapToStep( - value: number, - step: number, - min?: number, - max?: number, -): number { - const lo = min ?? Number.NEGATIVE_INFINITY; - const hi = max ?? Number.POSITIVE_INFINITY; - const clamped = Math.min(Math.max(value, lo), hi); - const stepStr = String(step); - const decimals = stepStr.includes(".") ? stepStr.split(".")[1].length : 0; - const base = Number.isFinite(lo) ? lo : 0; - const snapped = base + Math.round((clamped - base) / step) * step; - const reclamped = Math.min(Math.max(snapped, lo), hi); - return Number(reclamped.toFixed(decimals)); -} - -function NumericValueInput({ - value, - min, - max, - step, - onChange, - displayValue, - className, - ariaLabel, - size: sizeAttr, - disabled = false, -}: { - value: number; - min?: number; - max?: number; - step: number; - onChange: (v: number) => void; - displayValue?: string; - className?: string; - ariaLabel?: string; - size?: number; - disabled?: boolean; -}) { - const [focused, setFocused] = useState(false); - const [draft, setDraft] = useState(""); - const cancelBlurCommitRef = useRef(false); - - const commit = (raw: string) => { - const parsed = Number.parseFloat(raw); - if (!Number.isFinite(parsed)) { - return; - } - const final = snapToStep(parsed, step, min, max); - if (final !== value) { - onChange(final); - } - }; - - const displayed = focused ? draft : (displayValue ?? String(value)); - - return ( - { - cancelBlurCommitRef.current = false; - setDraft(String(value)); - setFocused(true); - // Defer select() so it runs after the value swap above. - const target = e.currentTarget; - requestAnimationFrame(() => target.select()); - }} - onBlur={() => { - if (cancelBlurCommitRef.current) { - cancelBlurCommitRef.current = false; - } else { - commit(draft); - } - setFocused(false); - }} - onChange={(e) => setDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.currentTarget.blur(); - } else if (e.key === "Escape") { - cancelBlurCommitRef.current = true; - setDraft(String(value)); - e.currentTarget.blur(); - } - }} - className={cn("panel-number-input", className)} - /> - ); -} - -function ParamSlider({ +export function ParamSlider({ label, value, min, @@ -285,6 +155,7 @@ function ParamSlider({ displayValue={displayValue} ariaLabel={label} size={valueSize ?? 4} + className="panel-number-input" disabled={disabled} /> @@ -385,8 +256,7 @@ function CollapsibleSection({ return (
{labelHref ? ( @@ -458,6 +328,7 @@ interface ChatSettingsPanelProps { onOpenChange?: (open: boolean) => void; params: InferenceParams; onParamsChange: (params: InferenceParams) => void; + modelConfig?: ReactNode; isExternalModel?: boolean; /** * Sampling-param capabilities for the active external provider, or `null` for @@ -472,21 +343,6 @@ interface ChatSettingsPanelProps { * Max Tokens floor in the slider. */ externalProviderType?: string | null; - onReloadModel?: () => void; - /** The in-flight load (id + GGUF variant + native path token), or null when - * idle. Used to show a loading state for the staged pick only — not for an - * unrelated load or a cancel's background unload. */ - loadingModel?: { - id: string; - ggufVariant?: string | null; - nativePathToken?: string | null; - } | null; - /** Loads the staged `pendingSelection` (deferred "Load on selection" flow). */ - onLoadPendingModel?: () => void; - /** Download progress (0–1) for a staged GGUF being fetched, or null when idle. */ - stagedDownloadFraction?: number | null; - /** Cancels the in-flight staged download (paired with abandoning the stage). */ - onCancelStagedDownload?: () => void; } export function ChatSettingsPanel({ @@ -494,16 +350,12 @@ export function ChatSettingsPanel({ onOpenChange, params, onParamsChange, + modelConfig = null, isExternalModel = false, providerCapabilities = null, activeExternalProvider = null, onExternalProviderChange, externalProviderType = null, - onReloadModel, - loadingModel = null, - onLoadPendingModel, - stagedDownloadFraction, - onCancelStagedDownload, }: ChatSettingsPanelProps) { // Local models show every knob; providerCapabilities is only consulted when // isExternalModel. Unknown providers fall back to the OpenAI-compat shape via @@ -518,64 +370,23 @@ export function ChatSettingsPanel({ const showPresencePenalty = !isExternalModel || Boolean(providerCapabilities?.presencePenalty); const isMobile = useIsMobile(); - const pendingSelection = useChatRuntimeStore((s) => s.pendingSelection); - // "Loading" only when the in-flight load IS this staged pick (full id + GGUF - // variant + native token match), not an unrelated load or a cancel's - // background unload. The variant matters: a different quant of the same repo - // staged mid-load must not read as this one loading. - const stagedLoading = - loadingModel != null && - pendingSelectionMatches(pendingSelection, { - id: loadingModel.id, - ggufVariant: loadingModel.ggufVariant, - nativePathToken: loadingModel.nativePathToken, - }); - // Load settings are snapshotted at click time; lock them while loading. - const modelControlsDisabled = stagedLoading; - const abandonStagedModel = useChatRuntimeStore((s) => s.abandonStagedModel); - const resetModelSettingsToLoaded = useChatRuntimeStore( - (s) => s.resetModelSettingsToLoaded, + const isLoadedGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null; + const currentCheckpoint = params.checkpoint; + const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength); + // Direct-file / custom-folder GGUFs load without a variant label but still + // report a GGUF context, so detect them via the context and the checkpoint + // suffix too (mirrors the chat page's activeModelIsGguf). Otherwise Max Tokens + // would fall back to params.maxSeqLength instead of the loaded GGUF context. + const isGguf = + isLoadedGguf || + ggufContextLength != null || + (currentCheckpoint?.toLowerCase().endsWith(".gguf") ?? false); + const ggufMaxContextLength = useChatRuntimeStore( + (s) => s.ggufMaxContextLength, ); - // A staged GGUF pick (deferred load) shows the GGUF load knobs so they can be - // set before the single load. - const pendingIsGguf = isPendingGguf(pendingSelection); - // Short, human-readable name for the staged pick (HF ids carry an org prefix; - // native picks are already a display label). Drives the "staged, not loaded" - // callout so it's obvious the selection hasn't loaded yet. - const stagedLabel = (() => { - const id = pendingSelection?.id ?? ""; - const slash = id.lastIndexOf("/"); - const base = slash >= 0 ? id.slice(slash + 1) : id; - return base || id; - })(); - const activeNativePathToken = useChatRuntimeStore( - (s) => s.activeNativePathToken, - ); - const loadedGgufContextLength = useChatRuntimeStore((s) => s.ggufContextLength); - // A GGUF loaded from a native path / direct .gguf has no HF variant, so key - // off the same signal the status hydration uses -- variant OR native token OR - // a GGUF context -- else the GPU Memory controls hide for a loaded local GGUF. - const isLoadedGguf = - useChatRuntimeStore((s) => s.activeGgufVariant) != null || - activeNativePathToken != null || - loadedGgufContextLength != null; - // While a pick is staged the sheet configures *that* model, so its GGUF-ness - // (not the currently loaded model's) decides whether the GGUF-only controls - // show. Otherwise a staged non-GGUF Hub repo would inherit the loaded GGUF's - // context/KV/speculative controls. - const isGguf = pendingSelection != null ? pendingIsGguf : isLoadedGguf; - // The Model section (and Load button) shows for any staged pick, even when the - // currently active model is external. - const hasModelContent = - pendingSelection != null || - (!isExternalModel && (isGguf || Boolean(params.checkpoint))); + const customContextLength = useChatRuntimeStore((s) => s.customContextLength); const speculativeType = useChatRuntimeStore((s) => s.speculativeType); - const setSpeculativeType = useChatRuntimeStore((s) => s.setSpeculativeType); - const loadedSpeculativeType = useChatRuntimeStore( - (s) => s.loadedSpeculativeType, - ); const specFallbackReason = useChatRuntimeStore((s) => s.specFallbackReason); - // Only binary fallback states are solved by a newer prebuilt. const mtpUpdatable = specFallbackReason === "binary_no_mtp" || specFallbackReason === "binary_outdated"; @@ -597,65 +408,27 @@ export function ChatSettingsPanel({ `llama.cpp updated to ${result.tag ?? "the latest build"}.${reloadHint}`, ); } else { - toast.error(`llama.cpp update failed: ${result.error ?? "unknown error"}`); + toast.error( + `llama.cpp update failed: ${result.error ?? "unknown error"}`, + ); } }, [applyLlamaUpdate]); - const specDraftNMax = useChatRuntimeStore((s) => s.specDraftNMax); - const setSpecDraftNMax = useChatRuntimeStore((s) => s.setSpecDraftNMax); - const loadedSpecDraftNMax = useChatRuntimeStore( - (s) => s.loadedSpecDraftNMax, - ); - const currentCheckpoint = params.checkpoint; - const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength); - const ggufMaxContextLength = useChatRuntimeStore( - (s) => s.ggufMaxContextLength, - ); - const ggufNativeContextLength = useChatRuntimeStore( - (s) => s.ggufNativeContextLength, - ); - const kvCacheDtype = useChatRuntimeStore((s) => s.kvCacheDtype); - const setKvCacheDtype = useChatRuntimeStore((s) => s.setKvCacheDtype); - const applyRememberedLoadSettings = useChatRuntimeStore( - (s) => s.applyRememberedLoadSettings, - ); - const loadedKvCacheDtype = useChatRuntimeStore((s) => s.loadedKvCacheDtype); - const tensorParallel = useChatRuntimeStore((s) => s.tensorParallel); - const setTensorParallel = useChatRuntimeStore((s) => s.setTensorParallel); - const loadedTensorParallel = useChatRuntimeStore( - (s) => s.loadedTensorParallel, - ); - const gpuMemoryMode = useChatRuntimeStore((s) => s.gpuMemoryMode); - const setGpuMemoryMode = useChatRuntimeStore((s) => s.setGpuMemoryMode); - const loadedGpuMemoryMode = useChatRuntimeStore((s) => s.loadedGpuMemoryMode); - const loadedIsDiffusion = useChatRuntimeStore((s) => s.loadedIsDiffusion); - const gpuLayers = useChatRuntimeStore((s) => s.gpuLayers); - const setGpuLayers = useChatRuntimeStore((s) => s.setGpuLayers); - const loadedGpuLayers = useChatRuntimeStore((s) => s.loadedGpuLayers); - const nCpuMoe = useChatRuntimeStore((s) => s.nCpuMoe); - const setNCpuMoe = useChatRuntimeStore((s) => s.setNCpuMoe); - const loadedNCpuMoe = useChatRuntimeStore((s) => s.loadedNCpuMoe); - const splitRatio = useChatRuntimeStore((s) => s.splitRatio); - const setSplitRatio = useChatRuntimeStore((s) => s.setSplitRatio); - const loadedSplitRatio = useChatRuntimeStore((s) => s.loadedSplitRatio); - const ggufLayerCount = useChatRuntimeStore((s) => s.ggufLayerCount); - const moeLayerCount = useChatRuntimeStore((s) => s.moeLayerCount); - const selectedGpuIds = useChatRuntimeStore((s) => s.selectedGpuIds); - const setSelectedGpuIds = useChatRuntimeStore((s) => s.setSelectedGpuIds); - const loadedGpuIds = useChatRuntimeStore((s) => s.loadedGpuIds); - const gpuDevices = useGpuDevices(); - const chatTemplateOverride = useChatRuntimeStore( - (s) => s.chatTemplateOverride, - ); - const loadedChatTemplateOverride = useChatRuntimeStore( - (s) => s.loadedChatTemplateOverride, - ); - const customContextLength = useChatRuntimeStore((s) => s.customContextLength); - const loadedCustomContextLength = useChatRuntimeStore( - (s) => s.loadedCustomContextLength, - ); - const setCustomContextLength = useChatRuntimeStore( - (s) => s.setCustomContextLength, - ); + const loadedEffectiveContext = customContextLength ?? ggufContextLength; + const showSpecFallback = + !isExternalModel && + isGguf && + specFallbackReason != null && + (speculativeType === "auto" || + speculativeType === "mtp" || + speculativeType === "mtp+ngram"); + const showContextVramWarning = + !isExternalModel && + isGguf && + ggufMaxContextLength != null && + loadedEffectiveContext != null && + loadedEffectiveContext > ggufMaxContextLength; + const showLoadedDiagnostics = showSpecFallback || showContextVramWarning; + const hasModelContent = showLoadedDiagnostics; const setActivePresetSource = useChatRuntimeStore( (s) => s.setActivePresetSource, ); @@ -666,170 +439,7 @@ export function ChatSettingsPanel({ const setActivePreset = useChatRuntimeStore((s) => s.setActivePreset); const settingsHydrated = useChatRuntimeStore((s) => s.settingsHydrated); - // A staged (not-yet-loaded) GGUF carries its own header context length on - // pendingSelection, so the slider can use the staged model's real ceiling - // without reading the loaded model's `ggufContextLength`. - const stagedContextLength = pendingSelection?.contextLength ?? null; - // "Remember settings next time" tick for a staged model. Seeds the store from - // the saved per-model settings on stage, so the sheet opens with what was used - // last time; the tick reflects whether a saved entry exists. - const [remember, setRemember] = useState(false); - // Keyed per quant: a different variant of the same repo has its own settings. - const pendingKey = pendingSelection - ? rememberedLoadSettingsKey(pendingSelection) - : null; - useEffect(() => { - if (!pendingKey) return; - // GGUF-only, like the stageOrLoad / Hub restore paths: every remembered - // field is a llama.cpp knob, so a non-GGUF pick has nothing to restore -- - // and applying its blob would clobber the standing gpuMemoryMode with a - // stale snapshot (the save on Load below is gated the same way). - const saved = pendingIsGguf ? loadRememberedLoadSettings(pendingKey) : null; - setRemember(saved != null); - if (saved) applyRememberedLoadSettings(saved); - }, [pendingKey, pendingIsGguf, applyRememberedLoadSettings]); - // While staging, the sheet reflects the STAGED model, so its header context - // takes precedence over the loaded model's (which may differ or be larger). - const baseContext = pendingIsGguf ? stagedContextLength : ggufContextLength; - const baseNativeContext = pendingIsGguf - ? stagedContextLength - : ggufNativeContextLength; - // Context controls render once we actually have a ceiling: for a staged GGUF, - // once its header metadata arrives (post-download); otherwise post-load. - const showContextControl = pendingIsGguf - ? stagedContextLength != null - : isLoadedGguf; - const stagedDownloading = - stagedDownloadFraction != null && stagedDownloadFraction < 1; - const ctxDisplayValue = customContextLength ?? baseContext ?? ""; - const ctxMaxValue = baseNativeContext ?? baseContext ?? null; - const kvDirty = kvCacheDtype !== loadedKvCacheDtype; - const ctxDirty = customContextLength !== loadedCustomContextLength; - const specDirty = speculativeType !== loadedSpeculativeType; - const specDraftDirty = specDraftNMax !== loadedSpecDraftNMax; - const tpDirty = tensorParallel !== (loadedTensorParallel ?? false); - // A loaded diffusion GGUF runs mode-agnostic (pins all layers on one GPU, - // ignores --fit/--gpu-layers), so the GPU Memory mode + manual controls don't - // apply -- hide them and don't let the preserved standing mode read as dirty. - // The GPU picker still applies (diffusion pins the chosen device). A staged pick - // keeps the controls (a pending pick's diffusion-ness isn't known until load). - const gpuModeApplies = - isGguf && (pendingSelection != null || !loadedIsDiffusion); - const gpuDirty = - gpuModeApplies && gpuMemoryMode !== (loadedGpuMemoryMode ?? "auto"); - const isManual = gpuModeApplies && gpuMemoryMode === "manual"; - // Manual with the GPU Layers slider at "Auto" (leftmost): --fit owns the whole - // layout, so the offload knobs (MoE, split, TP) don't apply. - const autoLayers = isManual && gpuLayers < 0; - // GPUs actually in use: the picked subset, or all visible when none picked. - const gpusInUse = selectedGpuIds ?? gpuDevices.map((d) => d.index); - // The picker must keep one GPU selected. - const singleGpuInUse = gpusInUse.length <= 1; - // TP needs at least two GPUs because tensor split is a no-op on one and may - // abort. Auto layers hides TP because --fit aborts under --split-mode tensor. - const tpDisabled = singleGpuInUse; - // Manual gpu-layers ceiling = model layer count + 1 (else a safe fallback): - // llama.cpp counts the output layer as one more offloadable layer past the - // repeating blocks ("offloaded 33/33" needs -ngl 33 on a 32-block model), so - // the slider max must reach it or full offload is unreachable. While staging, - // use the staged model's layer count (read from its header). - const stagedLayerCount = pendingSelection?.layerCount ?? null; - const modelLayerCount = pendingIsGguf ? stagedLayerCount : ggufLayerCount; - const gpuLayersMax = modelLayerCount != null ? modelLayerCount + 1 : 256; - // MoE-offload slider: shown only for MoE models, capped at their MoE-layer - // count. While staging, use the staged model's count (read from its header); - // otherwise the loaded model's. - const stagedMoeLayerCount = pendingSelection?.moeLayerCount ?? null; - const moeLayersMax = pendingIsGguf - ? (stagedMoeLayerCount ?? 0) - : (moeLayerCount ?? 0); - const showMoeSlider = isManual && !autoLayers && moeLayersMax > 0; - // gpuLayers always counts; MoE only with an explicit layer count (see above). - const manualDirty = - isManual && - (gpuLayers !== loadedGpuLayers || - (!autoLayers && nCpuMoe !== (loadedNCpuMoe ?? 0))); - // GPU picker: only meaningful on multi-GPU, and only when the reported - // indices are physical (relative ordinals from a parent CUDA_VISIBLE_DEVICES - // mask can't be mapped back to pin a device). null = use all (auto). - const showGpuPicker = - isGguf && - gpuDevices.length > 1 && - gpuDevices.every((d) => d.physicalIndex); - const isGpuChecked = (index: number) => - selectedGpuIds === null || selectedGpuIds.includes(index); - const toggleGpu = (index: number) => { - const all = gpuDevices.map((d) => d.index); - const current = selectedGpuIds ?? all; - const next = current.includes(index) - ? current.filter((i) => i !== index) - : [...current, index].sort((a, b) => a - b); - if (next.length === 0) return; // keep at least one GPU selected - setSelectedGpuIds(next.length === all.length ? null : next); - // The per-GPU split is positional, so any change to the set of GPUs in use - // invalidates it: drop it (the sliders fall back to the VRAM-weighted - // default). TP needs 2+ GPUs, so disable it when only one remains. - setSplitRatio(null); - if (next.length <= 1) { - setTensorParallel(false); - } - }; - const gpuIdsKey = (ids: number[] | null) => (ids === null ? "auto" : ids.join(",")); - const gpuIdsDirty = gpuIdsKey(selectedGpuIds) !== gpuIdsKey(loadedGpuIds); - // Per-GPU layer split (--tensor-split): manual + 2+ GPUs in use. One slider - // per GPU, each a layer count; together they sum to the GPU Layers total. - const showSplitRatio = - isManual && !autoLayers && showGpuPicker && gpusInUse.length > 1; - // The total the per-GPU counts sum to (the GPU Layers slider value); 0 under - // Auto, where the split is hidden. The devices behind the GPUs in use, for - // labels + the VRAM-weighted default. - const splitTotal = Math.max(0, Math.min(gpuLayers, gpuLayersMax)); - const gpusInUseDevices = gpusInUse.map( - (i) => gpuDevices.find((d) => d.index === i) ?? null, - ); - // Displayed per-GPU counts. splitRatio is a stable reference balance (only a - // slider edit changes it), rescaled to the current total; deriving rather than - // mutating it on GPU Layers changes keeps the balance intact when the total - // passes through low values or Auto. No saved split: free-VRAM-weighted default - // (llama.cpp's unset default splits by free VRAM, so the first edit starts from - // the default's placement, not a total-VRAM ratio that can land layers on a - // busy GPU). A genuine 0 (a full GPU) is a real weight, not missing data: the - // probe's no-data case degrades to the total server-side, and an all-zero list - // falls back to an even split in distributeByWeight. Not yet sent. - const splitCounts = - splitRatio && splitRatio.length === gpusInUse.length - ? distributeByWeight(splitTotal, splitRatio) - : distributeByWeight( - splitTotal, - gpusInUseDevices.map((d) => d?.memoryFreeGb ?? d?.memoryTotalGb ?? 1), - ); - const setSplitCount = (k: number, v: number) => - setSplitRatio(rebalanceSplit(splitTotal, splitCounts, k, v)); - const splitRatioDirty = - isManual && - !autoLayers && - JSON.stringify(splitRatio ?? null) !== JSON.stringify(loadedSplitRatio ?? null); - // Auto-fit context (Manual + Auto layers): <= 0 means "Auto" (--fit sizes it); - // a positive value pins it. Surface the length --fit chose once it's loaded. - const fitCtxAuto = autoLayers && (customContextLength ?? 0) <= 0; - const loadedAutoLayers = - loadedGpuMemoryMode === "manual" && (loadedGpuLayers ?? GPU_LAYERS_AUTO) < 0; - const fitResolvedCtx = - fitCtxAuto && loadedAutoLayers ? ggufContextLength : null; - // A saved chat-template override is a reload-time setting too, so surface - // Apply for a template-only edit (otherwise it could never be applied). - const templateDirty = chatTemplateOverride !== loadedChatTemplateOverride; - const modelSettingsDirty = - kvDirty || - ctxDirty || - specDirty || - specDraftDirty || - tpDirty || - gpuDirty || - manualDirty || - gpuIdsDirty || - splitRatioDirty || - templateDirty; + const baseContext = ggufContextLength; const [presetNameInput, setPresetNameInput] = useState(activePreset); const [systemPromptEditorOpen, setSystemPromptEditorOpen] = useState(false); const [systemPromptDraft, setSystemPromptDraft] = useState(""); @@ -855,8 +465,7 @@ export function ChatSettingsPanel({ BUILTIN_PRESETS.find((preset) => preset.name === activePreset) ?? null, [activePreset], ); - const hasUnsavedPresetChanges = useMemo( - () => { + const hasUnsavedPresetChanges = useMemo(() => { if (activePresetDefinition == null) { return false; } @@ -864,9 +473,7 @@ export function ChatSettingsPanel({ return activePresetSource === "modified"; } return !isSamePresetConfig(activePresetDefinition.params, params); - }, - [activePresetDefinition, activePresetSource, params], - ); + }, [activePresetDefinition, activePresetSource, params]); const presetSaveState = useMemo( () => getPresetSaveState({ @@ -895,6 +502,14 @@ export function ChatSettingsPanel({ const externalSelection = currentCheckpoint ? parseExternalModelId(currentCheckpoint) : null; + const maxTokensMax = isExternalModel + ? getExternalMaxOutputTokens( + externalProviderType, + externalSelection?.modelId, + ) + : isGguf && baseContext + ? baseContext + : Math.max(64, params.maxSeqLength); const showOpenAICodeExecSection = activeExternalProvider != null && providerSupportsBuiltinCodeExecution( @@ -977,8 +592,7 @@ export function ChatSettingsPanel({ return; } const fallbackPreset = - BUILTIN_PRESETS.find((preset) => preset.name === "Default") ?? - null; + BUILTIN_PRESETS.find((preset) => preset.name === "Default") ?? null; const next = customPresets.filter((preset) => preset.name !== name); setCustomPresets(next); if (activePreset === name) { @@ -1090,7 +704,7 @@ export function ChatSettingsPanel({ Run settings - + - )} -
- )} - {(speculativeType === "mtp" || - speculativeType === "mtp+ngram") && ( -
-
- - Draft Tokens - - - Max MTP draft tokens per step - (--spec-draft-n-max). Lower = less wasted - draft decode; higher = bigger speedup when - acceptance stays high. Default: 2 on GPU, - 3 on CPU/Mac. - -
- { - const raw = e.target.value; - if (raw === "") { - setSpecDraftNMax(null); - return; - } - const parsed = Number.parseInt(raw, 10); - if (Number.isFinite(parsed)) { - const clamped = Math.max(1, Math.min(16, parsed)); - setSpecDraftNMax(clamped); - } - }} - data-test-id="spec-draft-n-max-input" - aria-label="Speculative decoding draft tokens" - className="h-7 w-[88px] rounded-full border-border bg-background hover:bg-accent/50 dark:border-transparent dark:bg-white/[0.05] dark:hover:bg-white/[0.1] pl-3 py-0 text-[13px] font-medium text-nav-fg outline-none focus-visible:ring-0" - /> -
- )} - - )} - {gpuModeApplies && ( -
-
- - GPU Memory - - -
-
- Default: Unsloth - fits the model and context to your GPUs. -
-
- Manual: set GPU - Layers yourself. Leave it on Auto to let llama.cpp size - the context and offload overflow (including MoE experts) - to RAM. -
-
-
-
-
- -
-
- )} - {isManual && ( - <> - - Layers to keep on the GPU (--gpu-layers); the rest run - on CPU. Auto lets llama.cpp size the split (and the - context) to fit VRAM. At the maximum, the whole model - is on the GPU. - - } - /> - {showMoeSlider && ( - - Keep the experts of this many MoE layers on the CPU - (--n-cpu-moe) to save VRAM. 0 = all experts on the - GPU; at the maximum, all are on the CPU. - - } - /> - )} - {showSplitRatio && ( -
-
- - Layers per GPU - - - Splits GPU Layers across GPUs (--tensor-split). - Without Tensor Parallelism each value is the layer - count on that GPU; with it, every GPU holds a slice - of each layer, so the values are only a ratio. - -
- {gpusInUseDevices.map((d, k) => ( - setSplitCount(k, v)} - valueSize={6} - disabled={modelControlsDisabled} - /> - ))} -
- )} - - )} - {showGpuPicker && ( -
-
- - GPUs - - - Which GPUs this model may use. Unchecked GPUs are hidden - from llama.cpp (CUDA_VISIBLE_DEVICES, or - HIP_VISIBLE_DEVICES on ROCm). Leave all checked to use - every GPU. At least one GPU must stay selected. - -
-
- {gpuDevices.map((d) => ( -
- - GPU {d.index}: {d.name} - {d.memoryTotalGb - ? ` · ${Math.round(d.memoryTotalGb)} GB` - : ""} - - toggleGpu(d.index)} - data-test-id={`gpu-pick-${d.index}`} - disabled={ - modelControlsDisabled || - (isGpuChecked(d.index) && singleGpuInUse) - } - /> -
- ))} -
-
- )} - {gpuModeApplies && !autoLayers && ( -
-
- - Tensor Parallelism - - - No effect on a single GPU. On multi-GPU setups, improves - tokens/sec during generation when using dense models. MoE - models don't benefit and can be much slower. - -
- -
- )} - - )} - {/* No persistent "enable custom code" toggle: it is consented per model - via the load-time review dialog. */} - {/* Apply/Reset belongs to the model-reload settings above (context - length, KV cache, speculative decoding). Render it here, before - the Chat Template row, so it never reads as attached to Chat - Template (which is edited via its own dialog). When a model is - staged (deferred load), Load/Cancel takes its place: there's - nothing loaded to "apply" against yet. */} - {pendingSelection ? ( -
- {stagedDownloading && ( -

- Downloading…{" "} - {Math.round((stagedDownloadFraction ?? 0) * 100)}% + : "" + }`}

- )} - {/* GGUF picks only: a non-GGUF pick shows none of the load - knobs the blob captures, so there is nothing to remember. */} - {pendingIsGguf && ( - - )} - {stagedLoading ? ( - // Mid-load: nothing to load or abandon until it settles, so disable. - - ) : ( -
+ {mtpUpdatable && llamaUpdateStatus?.update_available && ( - -
- )} -
- ) : modelSettingsDirty ? ( -
- - -
- ) : null} - {/* The template override is a load-time knob too (applied on the next - reload) and the in-flight load already snapshotted it, so lock its - editors like the sibling controls -- a mid-load save would be - silently clobbered by the load response despite its toast. */} - - - + )} + + )} + {showContextVramWarning && ( +

+ Context length exceeds the estimated VRAM capacity ( + {ggufMaxContextLength?.toLocaleString()} tokens). The + model may use system RAM. +

+ )} + + )}
- +
savePresetWithName(presetNameInput)} disabled={!(settingsHydrated && presetSaveState.canSubmit)} - variant={presetSaveState.isSaveReady ? "default" : "outline"} + variant={ + presetSaveState.isSaveReady ? "default" : "outline" + } size="sm" className={cn( "h-9 w-full rounded-full text-[13px] font-medium tracking-nav", @@ -1850,7 +912,8 @@ export function ChatSettingsPanel({ Prompt caching - Reuse compatible prompt prefixes for lower latency and cost. + Reuse compatible prompt prefixes for lower latency and + cost.
Anthropic exposes a 5 minute and a 1 hour ephemeral - cache pool. The 1 hour pool costs 2x base input on - write vs 1.25x for 5 minute, but reads stay 0.1x for - both, so a single read landing more than 5 minutes - after the write pays off the premium. + cache pool. The 1 hour pool costs 2x base input on write + vs 1.25x for 5 minute, but reads stay 0.1x for both, so + a single read landing more than 5 minutes after the + write pays off the premium.
`: the closer need not match the opener.""" + text = '## 1.0\n\n\n' + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0", "9.9.9"] + + +@pytest.mark.parametrize("tag", ["details", "div", "table"]) +def test_type_6_blocks_run_until_a_blank_line(changelog_module, tag): + """`
` holds Markdown only after a blank line closes the block, so + a heading pressed against the opening tag is not a release.""" + packed = f"## 1.0\n\n<{tag}>\n## 9.9.9\n\n\n- note\n" + assert [e.version for e in changelog_module.parse_changelog(packed)] == ["1.0"] + spaced = f"## 1.0\n\n<{tag}>\n\n## 2.0\n\n- note\n" + assert [e.version for e in changelog_module.parse_changelog(spaced)] == ["1.0", "2.0"] + + +def test_a_tag_only_line_cannot_interrupt_a_paragraph(changelog_module): + """Type 7 blocks do not interrupt a paragraph, so prose followed by a bare + tag keeps the releases below it reachable.""" + text = "## 2.0\n\nSome prose.\n\n\n## 1.0\n\n- older\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"] + + +def test_preview_joins_an_indented_continuation_line(): + """Four spaces only start code outside a paragraph. Inside one the line is + a wrapped continuation, so it must not be dropped from the preview.""" + src = PREVIEW.read_text(encoding = "utf-8") + # Measured from the line's container, so an item's own indent does not count. + assert "!insideBlock && line.indent - line.column >= INDENTED_CODE_INDENT" in src + # A fence indented into a list item is a block, not a wrapped line. + assert "opensDeepFence" in src + + +def test_every_packaging_path_snapshots_the_changelog(): + """`python -m build` and `pip install .` must ship the offline copy too, + so the snapshot is made by the build backend rather than by build.sh.""" + pyproject = (REPO / "pyproject.toml").read_text(encoding = "utf-8") + assert 'build_py = "_changelog_build.build_py"' in pyproject + hook = (REPO / "_changelog_build.py").read_text(encoding = "utf-8") + assert "studio" in hook and "CHANGELOG.md" in hook + # The hook has to reach the sdist, or building from one loses the snapshot. + manifest = (REPO / "MANIFEST.in").read_text(encoding = "utf-8") + assert "include _changelog_build.py" in manifest + assert "include CHANGELOG.md" in manifest + + +def test_preview_code_spans_need_a_matching_closer(): + """A closer is a run of the same length, so ``Use `` `x` `` `` keeps the + inner backticks the expanded notes show.""" + src = CODE_SPANS.read_text(encoding = "utf-8") + assert "candidate === ticks" in src, "a closer is a run of the same length" + assert "stripPadding" in src, "one space of padding is dropped, as in Markdown" + + +def test_preview_skips_thematic_breaks(): + """`- - -` renders as a rule, so it must not take a preview slot.""" + src = PREVIEW.read_text(encoding = "utf-8") + assert "THEMATIC_BREAK" in src + assert "THEMATIC_BREAK.test(visible)" in src + + +def test_preview_keeps_quoted_examples_out_of_the_headlines(): + """A quoted list is example output, not a change, so it never competes + with the release's own bullets.""" + src = PREVIEW.read_text(encoding = "utf-8") + assert "quoted: boolean" in src + assert "if (!line.quoted)" in src, "quoted bullets never become headlines" + + +def test_notes_panel_keeps_the_link_when_the_lookup_fails(): + """Retry is not the only route: the changelog page can be reachable even + when the backend lookup is not.""" + src = PANEL.read_text(encoding = "utf-8") + error_branch = src[src.index('if (state === "error")') :] + retry = error_branch.index("update-release-notes-retry") + assert error_branch.index("{link}") > retry, "link sits beside retry" + + +def test_hook_waits_for_the_desktop_auth_token(): + """The desktop popup can render before auto-auth installs its token, so a + missing token must not be recorded as a failed lookup.""" + src = NOTES_HOOK.read_text(encoding = "utf-8") + assert "hasAuthToken()" in src and "AUTH_POLL_LIMIT" in src + + +def test_installed_layout_prefers_the_bundled_changelog(tmp_path): + """Installed, the levels above studio/ are site-packages. A stray + CHANGELOG.md left there by another package must not outrank the bundled + snapshot, so those levels are only searched in a source checkout.""" + site_packages = tmp_path / "site-packages" + package = site_packages / "studio/backend/utils" + package.mkdir(parents = True) + for name in ("changelog.py", "update_status.py"): + shutil.copy(BACKEND / "utils" / name, package / name) + for parent in (site_packages / "studio", package.parent, package): + (parent / "__init__.py").write_text("", encoding = "utf-8") + (site_packages / CHANGELOG.name).write_text("## 2.0\n\n- stray\n", encoding = "utf-8") + bundled = site_packages / "studio" / CHANGELOG.name + bundled.write_text("## 2.0\n\n- bundled\n", encoding = "utf-8") + + env = {**os.environ, "PYTHONPATH": str(site_packages)} + env.pop("UNSLOTH_CHANGELOG_PATH", None) + + def served() -> str: + # cwd is outside the checkout, so this imports the installed copy. + return subprocess.run( + [ + sys.executable, + "-c", + "from studio.backend.utils import changelog\n" + "print(changelog._read_local_changelog().text)", + ], + capture_output = True, + text = True, + env = env, + cwd = tmp_path, + check = True, + ).stdout + + assert "bundled" in served() and "stray" not in served() + + # A checkout marker there means it really is a repo root, so it wins again. + (site_packages / "pyproject.toml").write_text("", encoding = "utf-8") + assert "stray" in served() + + +def test_a_section_staged_as_a_comment_reads_as_unpublished( + changelog_module, tmp_path, monkeypatch +): + """Notes staged inside render as nothing, so the popup must say + no notes were published rather than show an empty surface.""" + monkeypatch.setenv(changelog_module.DISABLE_ENV_VAR, "1") + local = tmp_path / "CHANGELOG.md" + local.write_text("## 2.0\n\n\n\n## 1.0\n\n- shipped\n", encoding = "utf-8") + monkeypatch.setenv(changelog_module.CHANGELOG_PATH_ENV_VAR, str(local)) + changelog_module.reset_changelog_cache() + try: + staged = changelog_module.get_release_notes("2.0") + assert staged["matched"] is False and staged["markdown"] is None + assert changelog_module.get_release_notes("1.0")["matched"] is True + finally: + changelog_module.reset_changelog_cache() + + +@pytest.mark.parametrize( + "body,visible", + [ + ("- note", True), + ("", False), + ("```\n```", True), + ("
\n
", True), + (" ", False), + ], +) +def test_visibility_check_only_hides_comments(changelog_module, body, visible): + assert changelog_module._renders_visibly(body) is visible + + +@pytest.mark.parametrize( + "block", + [ + "", + "", + "", + ], +) +def test_processing_instructions_and_declarations_are_literal(changelog_module, block): + """Raw block types 3 to 5 render literally, like
, so a heading inside
+    one is a sample and not a release."""
+    text = f"## 1.0\n\n{block}\n\n- real note\n"
+    assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"]
+    assert "real note" in changelog_module.find_release_notes(text, "1.0").body
+
+
+def test_headings_need_a_space_or_tab_after_the_hashes(changelog_module):
+    """A non-breaking space pasted from rich text renders as ordinary text, so
+    the line must not end the release above it."""
+    text = "## 1.0\n\n- real note\n\n## 9.9.9\n\n- not a release\n"
+    assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"]
+    assert changelog_module.find_release_notes(text, "9.9.9") is None
+    # A tab is valid and still opens a heading.
+    tabbed = "## 1.0\n\n- one\n\n##\t2.0\n\n- two\n"
+    assert [e.version for e in changelog_module.parse_changelog(tabbed)] == ["1.0", "2.0"]
+
+
+def test_preview_skips_every_raw_block_form():
+    """The extractor tracks the same block forms as the parser, so a sample
+    bullet inside one cannot become the collapsed headline."""
+    src = PREVIEW.read_text(encoding = "utf-8")
+    assert "RAW_BLOCKS" in src
+    assert "CDATA" in src and "[A-Za-z]" in src
+
+
+@pytest.mark.parametrize("banner", [WEB_BANNER, TAURI_BANNER])
+def test_expanded_popup_fits_a_short_viewport(banner):
+    """A window under roughly 430px high used to push the card's title and
+    dismiss control above the top of the screen."""
+    panel = PANEL.read_text(encoding = "utf-8")
+    # The notes region shrinks inside the capped card, so header and actions stay on screen.
+    assert "min-h-0 flex-1" in panel, "notes height must follow the viewport"
+    src = banner.read_text(encoding = "utf-8")
+    assert "max-h-[calc(100dvh_-_2rem)]" in src, "card is the backstop on tiny viewports"
+
+
+def test_relative_changelog_links_point_at_the_repository():
+    """CHANGELOG.md links are repository-relative. Rendered as-is they resolve
+    against Studio's origin, so the renderer blocks them."""
+    src = LINKS.read_text(encoding = "utf-8")
+    assert "https://github.com/unslothai/unsloth/blob/main/" in src
+    assert "https://raw.githubusercontent.com/unslothai/unsloth/main/" in src
+    # Absolute targets, fragments, fenced code and code spans stay untouched.
+    assert "ABSOLUTE" in src and "codeSpans" in src and "FENCE" in src
+    panel = PANEL.read_text(encoding = "utf-8")
+    assert "resolveChangelogLinks" in panel
+
+
+@pytest.mark.parametrize("query", ["latest", "main", "not-a-version", "abc"])
+def test_unparseable_versions_are_rejected(changelog_module, query):
+    """Sections are indexed only when their version parses, so a query that
+    cannot parse can never match and is a bad request, not an empty result."""
+    assert changelog_module.is_supported_version_query(query) is False
+
+
+@pytest.mark.parametrize("query", ["2026.7.5", "v2026.7.5", "2026.07.5", "1.0.0rc1"])
+def test_real_versions_are_still_accepted(changelog_module, query):
+    assert changelog_module.is_supported_version_query(query) is True
+
+
+def test_reference_style_images_resolve_to_the_raw_host():
+    """`![alt][arch]` with `[arch]: docs/arch.png` needs the raw file: the blob
+    URL is an HTML page, so the image would not load."""
+    src = LINKS.read_text(encoding = "utf-8")
+    assert "IMAGE_REFERENCE" in src
+    assert "imageLabels" in src
+
+
+def test_collapsed_notes_surface_is_hidden_when_nothing_previews():
+    """Notes that are only a fenced command block preview as nothing, and an
+    empty muted strip is worse than no strip."""
+    src = PANEL.read_text(encoding = "utf-8")
+    assert "preview?.items.length === 0" in src
+
+
+def test_a_fence_closer_accepts_only_spaces_and_tabs(changelog_module):
+    """A delimiter followed by a non-breaking space is code content, so it must
+    not close the block and let a sample heading through."""
+    text = "## 1.0\n\n```\n```\u00a0\n## 9.9.9\n```\n\n- real note\n"
+    assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"]
+    plain = "## 1.0\n\n```\nx\n```\t\n\n## 2.0\n\n- two\n"
+    assert [e.version for e in changelog_module.parse_changelog(plain)] == ["1.0", "2.0"]
+    # The same rule in both frontend scanners.
+    for source in (PREVIEW, LINKS):
+        assert "/[^ \\t]/" in source.read_text(encoding = "utf-8")
+
+
+def test_code_spans_close_on_a_run_of_equal_length():
+    """`a``b [x](y.md)` is one code span, so the link inside it is literal."""
+    src = CODE_SPANS.read_text(encoding = "utf-8")
+    assert "candidate === ticks" in src, "closer length must match the opener"
+    # Shared, so the preview and the link resolver cannot drift apart.
+    assert "markdown-code-spans" in PREVIEW.read_text(encoding = "utf-8")
+    assert "markdown-code-spans" in LINKS.read_text(encoding = "utf-8")
+
+
+def test_preview_decodes_entities_like_the_renderer():
+    """Streamdown renders `AT&T` as AT&T, so the collapsed preview must
+    not show the raw entity."""
+    src = PREVIEW.read_text(encoding = "utf-8")
+    assert "NAMED_ENTITIES" in src and "decodeEntity" in src
+    # Decoded before code spans are restored, so code keeps the literal text.
+    assert src.index(".replace(ENTITY, decodeEntity)") < src.index(".replace(PARKED")
+
+
+def test_release_notes_request_refreshes_an_expired_token():
+    """A direct fetch cannot recover from a 401; authFetch refreshes first."""
+    src = NOTES_HOOK.read_text(encoding = "utf-8")
+    assert "authFetch(" in src
+    assert "getAuthToken" not in src
+
+
+def test_preview_handles_the_desktop_updater_line_endings():
+    """The updater body arrives with CRLF, which used to hide fences from the
+    extractor and promote a code sample to a headline."""
+    src = PREVIEW.read_text(encoding = "utf-8")
+    assert "LINE_ENDINGS" in src
+    assert "LINE_ENDINGS" in LINKS.read_text(encoding = "utf-8")
+
+
+def test_preview_renders_reference_links_as_text():
+    """`[text][label]` and `![alt][label]` render as a link and an image, so
+    the preview must not show their raw markup."""
+    src = PREVIEW.read_text(encoding = "utf-8")
+    assert "LINK_REFERENCE" in src and "IMAGE_REFERENCE" in src
+    # A definition line renders as nothing, so it is not a preview item.
+    assert "DEFINITION" in src
+
+
+def test_preview_treats_escaped_punctuation_as_literal():
+    """`\\*not italic\\*` keeps its stars and an escaped backtick does not open
+    a code span."""
+    assert "ESCAPE" in PREVIEW.read_text(encoding = "utf-8")
+    assert "escaped(" in CODE_SPANS.read_text(encoding = "utf-8")
+
+
+def test_link_resolver_skips_every_code_form():
+    """Indented code and code spans crossing a line render as code, so their
+    contents must not be rewritten."""
+    src = LINKS.read_text(encoding = "utf-8")
+    assert "INDENTED_CODE" in src
+    # Spans are scanned over the whole document, not line by line.
+    assert "codeSpans(masked)" in src
+    # A definition cannot interrupt a paragraph.
+    assert "definition.has(index)" in src
+
+
+def test_badge_links_resolve_both_targets():
+    """`[![alt](img)](link)` is the badge idiom: the outer link used to stay
+    relative because the label was not allowed to nest."""
+    assert "NESTED_LABEL" in LINKS.read_text(encoding = "utf-8")
+
+
+def test_in_flight_requests_are_identified_not_just_versioned():
+    """Two requests for the same version could resolve out of order and leave
+    the panel showing the older result."""
+    assert "requestIdRef" in NOTES_HOOK.read_text(encoding = "utf-8")
+
+
+def test_notes_repair_the_shared_previews_width_reset():
+    """MarkdownPreview clears max-width on every descendant, so a wide image
+    and the renderer's own link dialog escape the card."""
+    src = PANEL.read_text(encoding = "utf-8")
+    assert "[&_img]:max-w-full" in src
+    assert "[&_[data-streamdown=link-safety-modal]>*]:max-w-md" in src
+
+
+@pytest.mark.parametrize("banner", [WEB_BANNER, TAURI_BANNER])
+def test_only_the_notes_region_scrolls(banner):
+    """The dismiss control sits inside the card, so scrolling the card itself
+    carried it off screen on a short viewport."""
+    src = banner.read_text(encoding = "utf-8")
+    assert "flex max-h-[calc(100dvh_-_2rem)] flex-col overflow-hidden" in src
+    assert 'className="min-h-0 flex-1"' in src
+    panel = PANEL.read_text(encoding = "utf-8")
+    assert "max-h-64 min-h-0 flex-1 overflow-y-auto" in panel
+
+
+def test_a_comment_marker_in_prose_cannot_swallow_later_releases(changelog_module):
+    """A note that mentions `\n\n- note\n"
+    assert [e.version for e in changelog_module.parse_changelog(hidden)] == ["2.0"]
+
+
+def test_unmatched_backtick_runs_stay_linear(changelog_module):
+    """Rescanning the suffix for every opener was quadratic: a line of runs of
+    1, 2, 3 ... backticks, none of which ever closes, took 7.7s at 321 KB and
+    is reparsed on every popup request, so one malformed remote changelog could
+    tie up backend workers."""
+    line = "".join("`" * (i + 1) + "x" for i in range(800))
+    assert len(line) > 300_000
+    started = time.monotonic()
+    assert changelog_module._code_span_ranges(line) == []
+    assert time.monotonic() - started < 2.0
+
+
+def test_a_base_exception_releases_the_single_flight_flag(changelog_module, monkeypatch):
+    """The flag was cleared only after `except Exception`, so a BaseException
+    (KeyboardInterrupt, SystemExit, CancelledError) stranded it and every later
+    caller then waited out the full deadline for the life of the process."""
+    changelog_module.reset_changelog_cache()
+
+    def explode():
+        raise KeyboardInterrupt
+
+    monkeypatch.setattr(changelog_module, "_fetch_remote_changelog", explode)
+    with pytest.raises(KeyboardInterrupt):
+        changelog_module.get_remote_changelog()
+    assert changelog_module._remote_fetching is False
+    changelog_module.reset_changelog_cache()
+
+
+@pytest.mark.parametrize("marker", ["", ""])
+def test_an_empty_comment_does_not_swallow_later_releases(changelog_module, marker):
+    """`` and `` are complete comments in CommonMark: the closer
+    overlaps the opener. Searching for `-->` past the opener missed them, so an
+    empty comment used as a section marker hid every release below it."""
+    text = f"## 2.0\n\n- new stuff\n\n{marker}\n\n## 1.0\n\n- old stuff\n"
+    assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"]
+    assert changelog_module.find_release_notes(text, "1.0") is not None
+    assert "old stuff" not in changelog_module.find_release_notes(text, "2.0").body
+    # The frontend scanner has to agree, or the preview and the body disagree.
+    assert "!line.includes(COMMENT_CLOSE)" in PREVIEW.read_text(encoding = "utf-8")
+
+
+def test_an_unterminated_comment_still_hides_the_rest(changelog_module):
+    """The fix must not turn every `` or `
` is not a release.""" + for text in ( + "## 1.0\n\n## 9.9.9\n\n- note\n", + "## 1.0\n\n
\nx\n
## 9.9.9\n\n- note\n", + ): + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] + + +def test_an_exact_heading_is_never_shadowed(changelog_module): + """PEP 440 says 1.0 == 1.0.0, so the normalised match used to win even + when the file had a section spelled exactly as asked.""" + text = "## 1.0.0\n\n- padded\n\n## 1.0\n\n- exact\n" + assert changelog_module.find_release_notes(text, "1.0").body == "- exact" + assert changelog_module.find_release_notes(text, "1.0.0").body == "- padded" + # Normalised matching still applies when there is no exact heading. + assert changelog_module.find_release_notes("## 2026.7.6\n\n- x\n", "2026.07.6") is not None + + +def test_setext_headings_are_release_boundaries(changelog_module): + """A version over a line of dashes is the same heading in setext form.""" + text = "2.0\n---\n\n- new\n\n1.0\n---\n\n- old\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"] + assert changelog_module.find_release_notes(text, "2.0").body == "- new" + # A rule between sections is still a rule, and a setext h1 is not a release. + assert [ + e.version + for e in changelog_module.parse_changelog("## 2.0\n\n- a\n\n---\n\n## 1.0\n\n- b\n") + ] == ["2.0", "1.0"] + + +def test_a_long_backtick_run_does_not_stall_the_parser(changelog_module): + """The code-span guard used to backtrack: 20k backticks took over a minute + and every request re-parsed the file.""" + import time + + text = "## 1.0\n\n- " + "`" * 20_000 + " " * 16_000 + assert len(line) < changelog_module.CHANGELOG_MAX_BYTES + started = time.monotonic() + visible, in_comment = changelog_module._strip_comments(line, False, False) + elapsed = time.monotonic() - started + # Roughly 40ms scanning forward against roughly 11s restarting each time. + assert elapsed < 2.0, f"comment stripping took {elapsed:.1f}s" + # Same result as before: the spans survive and the comments are gone. + assert in_comment is False + assert "`\n- See [docs](docs/a.md)\n") + assert repo in spanned + # A comment starting a line is a block: it hides down to the closer's line, that line included. + block = run_scanner("links", "\n") + assert repo not in block + closer = run_scanner("links", " See [docs](docs/a.md)\n") + assert repo not in closer + + +def test_a_bare_level_two_marker_ends_the_release(changelog_module, run_scanner): + """An ATX heading's opening sequence may be followed by the end of the line + (spec 0.31.2 section 4.2), so a bare `##` is an empty level-two heading. The + scanners required whitespace after the hashes, so everything below such a + line stayed inside the release above it and the popup showed unrelated notes + under that version.""" + text = "## 2.0\n\n- new thing\n\n##\n\n- SECRET: not part of 2.0\n" + entry = changelog_module.find_release_notes(text, "2.0") + assert "new thing" in entry.body + assert "SECRET" not in entry.body + # An empty heading has no version, so it ends a release without indexing one. + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0"] + # Prose still needs a space or a tab: `##x` is a paragraph, not a heading. + prose = "## 2.0\n\n- new thing\n\n##x\n\n- still 2.0\n" + assert "still 2.0" in changelog_module.find_release_notes(prose, "2.0").body + # The preview agrees: an empty heading renders as nothing, so it ends the bullet. + preview = run_scanner("preview", "- new thing\n##\nUnrelated scratch notes\n") + assert preview_leads(preview) == ["new thing"] + + +def test_a_comment_between_bullets_closes_the_list(changelog_module, run_scanner): + """A comment is an HTML block (spec 0.31.2 section 4.6, type 2), so one + written at the margin under a bullet is not indented enough to continue that + item and closes the list. The scanners blanked the line before list tracking + saw it, which reads as a blank line and leaves the item open, so the release + heading below it looked like nested item content and the new release was + merged into the one above.""" + text = "## 1.0\n\n- old item\n\n ## 2.0\n\n- new item\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0", "2.0"] + assert "new item" not in changelog_module.find_release_notes(text, "1.0").body + assert "new item" in changelog_module.find_release_notes(text, "2.0").body + # At the item's content column the comment stays inside it, so the heading under it is nested. + nested = "## 1.0\n\n- old item\n \n ## 2.0\n\n- new item\n" + assert [e.version for e in changelog_module.parse_changelog(nested)] == ["1.0"] + # The link resolver reads the same column: list closed, four spaces is code, left untouched. + code = run_scanner("links", "- old item\n\n [guide](docs/a.md)\n") + assert "[guide](docs/a.md)" in code and "github.com" not in code + # Inside the item those four spaces are two columns in, so it is prose and the link resolves. + prose = run_scanner("links", "- old item\n \n [guide](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in prose + # The preview agrees: the fence is indented code, not a fence swallowing the bullet below. + preview = run_scanner( + "preview", + "- Details:\n\n ```\n - hidden sample\n- Real second item\n", + ) + assert preview_leads(preview) == ["Details:", "Real second item"] + + +def test_a_parenthesised_link_destination_still_resolves(run_scanner): + """A destination may hold parentheses while they balance (spec 0.31.2 + section 6.3), so `[x]((draft).md)` points at `(draft).md`. The resolver's + destination expression stopped at the first paren, matched an empty + destination and left the markdown alone, so the link resolved against + Studio's own origin instead of the repository.""" + leading = run_scanner("links", "[details]((draft).md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/(draft).md" in leading + # An image resolves against the raw host the same way. + image = run_scanner("links", "![shield]((badge).png)\n") + assert "https://raw.githubusercontent.com/unslothai/unsloth/main/(badge).png" in image + # A pair in the middle of a path balances too. + middle = run_scanner("links", "[api](docs/(v2)/api.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/(v2)/api.md" in middle + # An unbalanced paren makes the destination invalid, so `[x](a(b.md)` is plain text, not a link. + unbalanced = run_scanner("links", "[x](a(b.md)\n") + assert unbalanced == "[x](a(b.md)\n" + # One more closer balances the pair, and then it is a link again. + closed = run_scanner("links", "[x](a(b.md))\n") + assert "https://github.com/unslothai/unsloth/blob/main/a(b.md)" in closed + # Pairs nest, and one level was all the expression allowed, so a path with two stayed relative. + nested = run_scanner("links", "[x](((draft)).md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/((draft)).md" in nested + deep = run_scanner("links", "![shot](((((v2))))).png)\n") + assert "https://raw.githubusercontent.com/unslothai/unsloth/main/((((v2))))" in deep + # The closer must still be there: an unbalanced run below a nested pair is not a link. + across = run_scanner("links", "[x](((a).md\n[y](docs/y.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/y.md" in across + assert "[x](((a).md" in across + + +def test_a_fence_inside_a_container_still_hides_its_sample(run_scanner): + """A fence is measured from its container and not from the margin (spec + 0.31.2 section 4.5), so `> ~~~` and a fence three columns under a nested + bullet open one. Reading the margin instead never saw them, so the sample + inside was treated as prose and a relative link written in a code block was + rewritten into the text the reader sees verbatim.""" + quoted = run_scanner("links", "> ~~~\n> [guide](docs/a.md)\n> ~~~\n") + assert "[guide](docs/a.md)" in quoted and "github.com" not in quoted + nested = run_scanner("links", "- a\n - b\n ~~~\n [x](docs/x.md)\n ~~~\n") + assert "[x](docs/x.md)" in nested and "github.com" not in nested + # A longer closer is still a closer, so the pair is not something a code span hid. + uneven = run_scanner("links", "> ```\n> [guide](docs/a.md)\n> ````\n") + assert "[guide](docs/a.md)" in uneven and "github.com" not in uneven + # The fence ends with its container: a line outside the quote, or left of the item, is Markdown. + left = run_scanner("links", "> ~~~\n[guide](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in left + dedented = run_scanner("links", "- a\n ~~~\n[guide](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in dedented + # A document-level fence owns the quoted lines below, so the marker does not undo it. + document = run_scanner("links", "~~~\n> [guide](docs/a.md)\n~~~\n") + assert "[guide](docs/a.md)" in document and "github.com" not in document + # Four columns past the item's content column it is indented code, not a fence: still literal. + code = run_scanner("links", "- Details:\n\n ~~~\n [guide](docs/a.md)\n") + assert "[guide](docs/a.md)" in code and "github.com" not in code + + +def test_an_html_block_inside_a_container_is_literal_too(run_scanner): + """Type 1 and type 6 blocks are measured from their container the same way, + so a `
` under a nested bullet and a `
` inside a quote both
+    show their contents verbatim. Missing the opener treated the body as
+    Markdown and rewrote the literal examples in it."""
+    nested = run_scanner("links", "- a\n  - b\n    
\n [x](docs/x.md)\n
\n") + assert "[x](docs/x.md)" in nested and "github.com" not in nested + quoted = run_scanner("links", ">
\n> [x](docs/x.md)\n> 
\n") + assert "[x](docs/x.md)" in quoted and "github.com" not in quoted + # The block ends with its container, so a line dedented out of the item is Markdown again. + dedented = run_scanner("links", "- a\n - b\n
\n[x](docs/x.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/x.md" in dedented + # Inside a quote a bare marker holds nothing, the blank line that ends a type 6 block. + blank = run_scanner("links", ">
\n>\n> [x](docs/x.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/x.md" in blank + + +def test_an_underline_left_of_an_item_is_lazy_text_of_it(changelog_module, run_scanner): + """A setext underline may never be a lazy continuation line (spec 0.31.2 + section 4.3), so `===` written left of an open list item is read as more of + the item's paragraph rather than as a block that closes it. Rejecting every + underline-shaped line ended the list there, which promoted the nested + "## 2.0" below it to a document-level heading and indexed a release the + renderer never shows.""" + nested = "## 1.0\n- old note\n===\n ## 2.0\n- new\n" + assert [e.version for e in changelog_module.parse_changelog(nested)] == ["1.0"] + # A row of dashes is a thematic break, closing the item, so the heading is the next release. + broken = "## 1.0\n- old note\n---\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(broken)] == ["1.0", "2.0"] + # With no paragraph above it the underline opens one, so the blank line closes the item. + apart = "## 1.0\n- old note\n\n===\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(apart)] == ["1.0", "2.0"] + # The link scanner keeps the item open, so the four-space line is a paragraph and resolves. + resolved = run_scanner("links", "- Details:\n===\n\n [guide](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in resolved + + +def test_a_quote_keeps_its_paragraph_to_itself(changelog_module, run_scanner): + """Lazy continuation runs the other way too: a marker written outside a + blockquote is not text of the quote's paragraph, so `2. item` under + `> quote` opens a list even though an ordered marker past 1 may not + interrupt a paragraph (spec 0.31.2 section 5.2). Lending the quote's + paragraph to the document left the list closed, so the heading indented to + the item's content column read as a release of its own.""" + quoted = "## 1.0\n> quote\n2. item\n ## 2.0\n- new\n" + assert [e.version for e in changelog_module.parse_changelog(quoted)] == ["1.0"] + # A quote holding a heading leaves no paragraph, nor does an empty one, so the list opens. + heading = "## 1.0\n> # inner\n2. item\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(heading)] == ["1.0"] + # An unquoted line the quote's paragraph swallows keeps it open, the marker still outside. + lazy = "## 1.0\n> quote\ntext\n2. item\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(lazy)] == ["1.0"] + # Under an ordinary paragraph the marker is its text, so no list opens and the heading is real. + prose = "## 1.0\nprose\n2. item\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(prose)] == ["1.0", "2.0"] + # The preview reads the marker as a bullet for the same reason. + assert preview_leads(run_scanner("preview", "> quote\n2. item\n")) == ["item"] + + +def test_indented_code_before_an_ordered_marker_still_opens_a_list(changelog_module): + """An indented code block ends at the first line that is not indented enough + to continue it, and no paragraph is open for the marker below to continue, + so `2. item` opens a list whatever its start number. Reading it as text of + the code block instead would leave the list closed and index the heading at + the item's content column as a release.""" + joined = "## 1.0\n\n code\n2. item\n ## 2.0\n- new\n" + assert [e.version for e in changelog_module.parse_changelog(joined)] == ["1.0"] + # A blank line between the two changes nothing: the list opens either way. + apart = "## 1.0\n\n code\n\n2. item\n ## 2.0\n- new\n" + assert [e.version for e in changelog_module.parse_changelog(apart)] == ["1.0"] + # Four columns past its container the marker is code, so no list opens and the heading stands. + inside = "## 1.0\n\n code\n - item\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(inside)] == ["1.0", "2.0"] + + +def test_a_fence_written_as_an_item_first_content_opens_in_that_item(run_scanner): + """A block written straight after a list marker is the item's own first + content, measured from the column that content starts (spec 0.31.2 section + 5.2), so "- ```md" opens a fence. Reading the whole line instead never saw + one, so the code sample below it was treated as prose: the resolver rewrote + a destination the reader sees verbatim, and the preview offered the info + string as a headline bullet.""" + sample = run_scanner("links", "- ```md\n [example](docs/a.md)\n ```\n") + assert "[example](docs/a.md)" in sample and "github.com" not in sample + ordered = run_scanner("links", "1. ~~~\n [example](docs/a.md)\n ~~~\n") + assert "[example](docs/a.md)" in ordered and "github.com" not in ordered + # The preview agrees: an item of only a code block previews as nothing; the next is a bullet. + preview = run_scanner("preview", "- ```md\n sample text\n ```\n- Added tests\n") + assert preview_leads(preview) == ["Added tests"] + # One column further in it is indented code inside the item, so the link is prose and resolves. + padded = run_scanner("links", "- ```\n [example](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in padded + # A marker the paragraph above swallows opens no item, so no fence: ordered items open at 1. + lazy = run_scanner("links", "Intro.\n2. ```\n[guide](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in lazy + + +def test_an_html_block_ends_with_the_item_it_was_written_in(changelog_module, run_scanner): + """An HTML block holds no lazy continuation line, so one opened on a list + item's continuation line ends where the item does, exactly as a fence there + does. Ending it only on a blank line let it run past the item and swallow + the next release heading, so those notes could never be found, and the + collapsed preview lost every bullet below it.""" + text = "## 1.0\n\n- item\n\n
\n## 2.0\n\n- new thing\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0", "2.0"] + assert "new thing" in changelog_module.find_release_notes(text, "2.0").body + # A raw block such as
 is scoped the same way.
+    raw = "## 1.0\n\n- item\n\n  
\n## 2.0\n\n- new thing\n"
+    assert [e.version for e in changelog_module.parse_changelog(raw)] == ["1.0", "2.0"]
+    # At the item's content column the block holds the heading, which is nested and indexes nothing.
+    nested = "## 1.0\n\n- item\n\n  
\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(nested)] == ["1.0"] + # The preview reads it the same way: the bullet below the block is a bullet. + preview = run_scanner("preview", "- item\n\n
\n- Added tests\n") + assert preview_leads(preview) == ["item", "Added tests"] + # An opener straight after a marker opens in that item, so the dedented heading is a release. + marked = "## 1.0\n\n-
\n## 2.0\n\n- new thing\n" + assert [e.version for e in changelog_module.parse_changelog(marked)] == ["1.0", "2.0"] + + +def test_a_comment_may_close_on_a_later_line_of_its_paragraph(run_scanner): + """A comment written mid-sentence is inline raw HTML belonging to the + paragraph around it, so its `-->` may arrive on a later line of that same + paragraph and everything between renders as nothing. Ending the comment at + its own line left a backtick inside it pairing with a real one below, which + hid a following link from the resolver, and left the collapsed preview + quoting text the popup body does not show.""" + carried = run_scanner("links", "Note see [d](docs/a.md) and `x`\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in carried + # Text inside the comment renders as nothing, so it is left alone. + inside = run_scanner("links", "Note end\n") + assert "[c](docs/c.md)" in inside and "github.com" not in inside + # The preview hides it too, rather than quoting the comment at the reader. + preview = run_scanner( + "preview", "- Added X \n- Second\n" + ) + assert preview_leads(preview) == ["Added X", "Second"] + # An opener cannot outlive its paragraph: with it closed the ` end [d](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in broken + # A heading breaks into the paragraph, so it ends the comment's reach too. + headed = run_scanner("links", "Note end [d](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in headed + assert preview_leads(run_scanner("preview", "Note ` written on a line of + its own, and a wrapped line may open with emphasis. The guard asking whether + the closer is reachable read any line whose first character was punctuation + as the start of a new block, so neither shape counted as more of the + paragraph carrying the comment. The comment then never closed, and the + collapsed popup showed the author's internal note to the user.""" + closer = run_scanner( + "preview", + "- DoRA training is available in Studio. \n", + ) + assert preview_leads(closer) == ["DoRA training is available in Studio."] + # A continuation may open with emphasis, which is text and not a block. + starred = run_scanner( + "preview", + "- DoRA training is available. \n", + ) + assert preview_leads(starred) == ["DoRA training is available."] + underscored = run_scanner( + "preview", + "- DoRA training is available. \n", + ) + assert preview_leads(underscored) == ["DoRA training is available."] + # A real block still ends the paragraph, so the opener below one is text and hides nothing. + broken = run_scanner("links", "Note [d](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in broken + # So does a list item with content, which may interrupt a paragraph. + item = run_scanner("links", "Note [d](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in item + + +def test_a_comment_written_as_an_item_first_content_is_a_block(changelog_module, run_scanner): + """A comment is an HTML block (spec 0.31.2 section 4.6, type 2), so one + written as a list item's first content opens inside that item, exactly as a + fence written there does. The scanners looked for the opener at the margin + of the line as written, so a marker in front of it hid the block: the + resolver rewrote a destination inside raw HTML, which Streamdown then shows + the reader as a literal URL, and the preview quoted the hidden note back at + them as though the bullet were Markdown.""" + item = run_scanner("links", "- AMD support, see [the guide](docs/amd.md)\n") + assert item == "- AMD support, see [the guide](docs/amd.md)\n" + # Every marker opens an item, and a nested one is still an item. + for text in ( + "* see [the guide](docs/amd.md)\n", + "1. see [the guide](docs/amd.md)\n", + "- outer\n - see [the guide](docs/amd.md)\n", + ): + assert "github.com" not in run_scanner("links", text) + # The multiline form hides lines to the closer, as a comment at the item's content column did. + multiline = run_scanner("links", "- \n") + assert "[a](docs/x.md)" in multiline and "github.com" not in multiline + # Still scoped to the item it was written in, so a line dedented out of it ends the block. + dedented = run_scanner("links", "- hidden note\n- Real bullet\n") + assert preview_leads(preview) == ["Real bullet"] + # The parser agrees too: the item keeps its column, so a heading inside is nested, not indexed. + text = "## 1.0\n\n-