Merge remote-tracking branch 'origin/main' into studio-security-headers-pure-asgi
This commit is contained in:
commit
57e888dc7b
11 changed files with 453 additions and 114 deletions
|
|
@ -55,6 +55,9 @@ class LlamaUpdateStatusResponse(BaseModel):
|
|||
source_build: bool = Field(
|
||||
False, description = "True when there is no marker (source build) but a prebuilt is offered."
|
||||
)
|
||||
update_size_bytes: Optional[int] = Field(
|
||||
None, description = "Download size of the prebuilt Update would fetch, in bytes."
|
||||
)
|
||||
job: LlamaUpdateJob = Field(default_factory = LlamaUpdateJob)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -520,3 +520,114 @@ def test_in_memory_only_reset_replays_stale_same_base_mix(monkeypatch, tmp_path)
|
|||
assert info["latest_tag"] == "b9596-mix-aaa"
|
||||
assert info["behind"] is True
|
||||
assert info["stale"] is True
|
||||
|
||||
|
||||
# update_download_size_bytes (banner download-size lookup).
|
||||
|
||||
|
||||
def _patch_assets(monkeypatch, mapping):
|
||||
"""Stub latest_release_assets with a per-repo {asset_name: size} lookup."""
|
||||
monkeypatch.setattr(
|
||||
fr,
|
||||
"latest_release_assets",
|
||||
lambda repo, *, force_refresh = False: mapping.get(repo),
|
||||
)
|
||||
|
||||
|
||||
def test_update_size_unsloth_prebuilt_exact_match(monkeypatch):
|
||||
# The unsloth fork's own bundle (app-<tag>-<platform>): the want= exact match
|
||||
# on app-<latest>-<suffix> wins.
|
||||
marker = {
|
||||
"asset": "app-b9190-linux-x64-cuda13-newer.tar.gz",
|
||||
"published_repo": "unslothai/llama.cpp",
|
||||
}
|
||||
_patch_assets(
|
||||
monkeypatch,
|
||||
{
|
||||
"unslothai/llama.cpp": {
|
||||
"app-b9300-linux-x64-cuda13-newer.tar.gz": 123_456_789,
|
||||
"app-b9300-windows-x64-cuda13-newer.zip": 999,
|
||||
}
|
||||
},
|
||||
)
|
||||
assert fr.update_download_size_bytes(marker, "b9300", "unslothai/llama.cpp") == 123_456_789
|
||||
|
||||
|
||||
def test_update_size_macos_fork_asset_suffix_fallback(monkeypatch):
|
||||
# macOS bundles use the upstream-style llama-<tag>-bin-macos-*, matched via the
|
||||
# endswith fallback in the publish repo.
|
||||
marker = {
|
||||
"asset": "llama-b9190-bin-macos-arm64.tar.gz",
|
||||
"published_repo": "unslothai/llama.cpp",
|
||||
}
|
||||
_patch_assets(
|
||||
monkeypatch,
|
||||
{"unslothai/llama.cpp": {"llama-b9300-bin-macos-arm64.tar.gz": 55_000_000}},
|
||||
)
|
||||
assert fr.update_download_size_bytes(marker, "b9300", "unslothai/llama.cpp") == 55_000_000
|
||||
|
||||
|
||||
def test_update_size_upstream_ubuntu_uses_binary_repo(monkeypatch):
|
||||
# #6338 P2: ggml-org ubuntu-* prebuilt lives in binary_repo, not the fork
|
||||
# publish repo. The size must still resolve.
|
||||
marker = {
|
||||
"asset": "llama-b9190-bin-ubuntu-x64.tar.gz",
|
||||
"published_repo": "unslothai/llama.cpp",
|
||||
"binary_repo": "ggml-org/llama.cpp",
|
||||
}
|
||||
_patch_assets(
|
||||
monkeypatch,
|
||||
{
|
||||
"unslothai/llama.cpp": {"app-b9300-linux-x64-cuda13-newer.tar.gz": 1},
|
||||
"ggml-org/llama.cpp": {
|
||||
"llama-b9673-bin-ubuntu-x64.tar.gz": 42_000_000,
|
||||
"llama-b9673-bin-ubuntu-vulkan-x64.tar.gz": 7,
|
||||
},
|
||||
},
|
||||
)
|
||||
assert fr.update_download_size_bytes(marker, "b9300", "unslothai/llama.cpp") == 42_000_000
|
||||
|
||||
|
||||
def test_update_size_upstream_windows_uses_binary_repo(monkeypatch):
|
||||
# Regression (#6338 P2): the Windows upstream CPU prebuilt uses a win-* token.
|
||||
marker = {
|
||||
"asset": "llama-b9190-bin-win-cpu-x64.zip",
|
||||
"published_repo": "unslothai/llama.cpp",
|
||||
"binary_repo": "ggml-org/llama.cpp",
|
||||
}
|
||||
_patch_assets(
|
||||
monkeypatch,
|
||||
{"ggml-org/llama.cpp": {"llama-b9673-bin-win-cpu-x64.zip": 33_000_000}},
|
||||
)
|
||||
assert fr.update_download_size_bytes(marker, "b9300", "unslothai/llama.cpp") == 33_000_000
|
||||
|
||||
|
||||
def test_update_size_no_matching_asset_fails_open(monkeypatch):
|
||||
# A ROCm version drift (installed 6.4 vs latest 7.2) leaves no suffix match;
|
||||
# the helper fails open to None rather than guessing a wrong artifact.
|
||||
marker = {
|
||||
"asset": "llama-b9190-bin-ubuntu-rocm-6.4-x64.tar.gz",
|
||||
"published_repo": "unslothai/llama.cpp",
|
||||
"binary_repo": "ggml-org/llama.cpp",
|
||||
}
|
||||
_patch_assets(
|
||||
monkeypatch,
|
||||
{"ggml-org/llama.cpp": {"llama-b9673-bin-ubuntu-rocm-7.2-x64.tar.gz": 9}},
|
||||
)
|
||||
assert fr.update_download_size_bytes(marker, "b9300", "unslothai/llama.cpp") is None
|
||||
|
||||
|
||||
def test_update_size_missing_inputs_fail_open(monkeypatch):
|
||||
_patch_assets(
|
||||
monkeypatch,
|
||||
{"unslothai/llama.cpp": {"app-b9300-linux-x64-cpu.tar.gz": 5}},
|
||||
)
|
||||
# No marker, no latest tag, or no asset string -> None (never raise).
|
||||
assert fr.update_download_size_bytes(None, "b9300", "unslothai/llama.cpp") is None
|
||||
assert (
|
||||
fr.update_download_size_bytes(
|
||||
{"asset": "app-b9190-linux-x64-cpu.tar.gz"}, None, "unslothai/llama.cpp"
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert fr.update_download_size_bytes({"asset": None}, "b9300", "unslothai/llama.cpp") is None
|
||||
|
|
|
|||
|
|
@ -901,3 +901,49 @@ def test_start_update_marked_refuses_when_not_behind(monkeypatch, tmp_path):
|
|||
res = upd.start_update()
|
||||
assert res["started"] is False
|
||||
assert res["reason"] == "up_to_date"
|
||||
|
||||
|
||||
def test_status_update_available_includes_size(monkeypatch, tmp_path):
|
||||
# Marker (prebuilt) update path attaches the download size of the asset the
|
||||
# banner would fetch.
|
||||
binary = _write_install(tmp_path, "b9493", asset = "app-b9493-linux-x64-cuda13-newer.tar.gz")
|
||||
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
||||
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
|
||||
monkeypatch.setattr(
|
||||
freshness,
|
||||
"latest_release_assets",
|
||||
lambda repo, *, force_refresh = False: {
|
||||
"app-b9518-linux-x64-cuda13-newer.tar.gz": 88_000_000
|
||||
},
|
||||
)
|
||||
st = upd.get_update_status(force_refresh = True)
|
||||
assert st["update_available"] is True
|
||||
assert st["update_size_bytes"] == 88_000_000
|
||||
|
||||
|
||||
def test_status_source_build_includes_update_size(monkeypatch, tmp_path):
|
||||
# #6338 P3: a source build offered a prebuilt must carry the asset size too.
|
||||
binary = tmp_path / "llama.cpp" / "build" / "bin" / "llama-server"
|
||||
binary.parent.mkdir(parents = True)
|
||||
binary.write_text("stub") # no marker -> source build
|
||||
monkeypatch.setattr(upd, "_find_binary", lambda: str(binary))
|
||||
_prebuilt(
|
||||
monkeypatch,
|
||||
repo = "unslothai/llama.cpp",
|
||||
release_tag = "b9585",
|
||||
asset = "app-b9585-linux-x64-cpu.tar.gz",
|
||||
)
|
||||
monkeypatch.setattr(upd, "_installed_build_number", lambda b: None)
|
||||
monkeypatch.setattr(
|
||||
upd,
|
||||
"latest_release_assets",
|
||||
lambda repo, *, force_refresh = False: (
|
||||
{"app-b9585-linux-x64-cpu.tar.gz": 77_000_000}
|
||||
if repo == "unslothai/llama.cpp"
|
||||
else None
|
||||
),
|
||||
)
|
||||
st = upd.get_update_status()
|
||||
assert st["source_build"] is True
|
||||
assert st["update_available"] is True
|
||||
assert st["update_size_bytes"] == 77_000_000
|
||||
|
|
|
|||
|
|
@ -281,6 +281,7 @@ def test_kill_process_records_timestamp_on_actual_kill():
|
|||
backend = LlamaCppBackend.__new__(LlamaCppBackend)
|
||||
backend._process = None
|
||||
backend._healthy = False
|
||||
backend._stats_logger = None # _kill_process stops it in finally
|
||||
backend._stdout_thread = None
|
||||
backend._llama_log_fh = None
|
||||
backend._last_kill_monotonic = 0.0
|
||||
|
|
|
|||
|
|
@ -78,6 +78,27 @@ def test_status_response_exposes_source_build():
|
|||
rl.LlamaUpdateStatusResponse(**{**payload, "unexpected": 1})
|
||||
|
||||
|
||||
def test_status_response_exposes_update_size_bytes():
|
||||
payload = {
|
||||
"supported": True,
|
||||
"update_available": True,
|
||||
"stale": False,
|
||||
"installed_tag": "b9493",
|
||||
"latest_tag": "b9518",
|
||||
"published_repo": "unslothai/llama.cpp",
|
||||
"installed_at_utc": None,
|
||||
"age_days": None,
|
||||
"source_build": False,
|
||||
"update_size_bytes": 123_456_789,
|
||||
"job": {"state": "idle"},
|
||||
}
|
||||
model = rl.LlamaUpdateStatusResponse(**payload)
|
||||
assert model.model_dump()["update_size_bytes"] == 123_456_789
|
||||
# Omitted -> defaults to None (the offline / no-matching-asset case).
|
||||
without = {k: v for k, v in payload.items() if k != "update_size_bytes"}
|
||||
assert rl.LlamaUpdateStatusResponse(**without).model_dump()["update_size_bytes"] is None
|
||||
|
||||
|
||||
def test_status_handler_runs_off_event_loop(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ _INSTALL_MARKER_NAME = "UNSLOTH_PREBUILT_INFO.json"
|
|||
|
||||
_marker_cache: dict[str, Optional[dict]] = {}
|
||||
_release_memo: dict[str, tuple[float, Optional[str]]] = {}
|
||||
# Newest-release asset sizes (name -> bytes), memoized like the tag (24h TTL).
|
||||
_assets_memo: dict[str, tuple[float, dict[str, int]]] = {}
|
||||
|
||||
|
||||
def _cache_dir() -> Path:
|
||||
|
|
@ -180,6 +182,113 @@ def latest_published_release(repo: str, *, force_refresh: bool = False) -> Optio
|
|||
return latest
|
||||
|
||||
|
||||
def _fetch_latest_release_assets(repo: str, timeout: float = 5.0) -> Optional[dict[str, int]]:
|
||||
"""Asset name -> size (bytes) for the newest published release of `repo`,
|
||||
selected exactly like _fetch_latest_release_tag. None on any failure."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
url = f"https://api.github.com/repos/{repo}/releases?per_page=30"
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "unsloth-studio-freshness-check",
|
||||
}
|
||||
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
req = urllib.request.Request(url, headers = headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout = timeout) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except (
|
||||
urllib.error.URLError,
|
||||
urllib.error.HTTPError,
|
||||
OSError,
|
||||
json.JSONDecodeError,
|
||||
) as exc:
|
||||
logger.debug("freshness asset fetch failed", repo = repo, error = str(exc))
|
||||
return None
|
||||
if not isinstance(data, list):
|
||||
return None
|
||||
published = [
|
||||
r
|
||||
for r in data
|
||||
if isinstance(r, dict)
|
||||
and not r.get("draft")
|
||||
and not r.get("prerelease")
|
||||
and isinstance(r.get("tag_name"), str)
|
||||
and r.get("tag_name")
|
||||
]
|
||||
if not published:
|
||||
return None
|
||||
newest = max(published, key = lambda r: r.get("published_at") or "")
|
||||
assets: dict[str, int] = {}
|
||||
for a in newest.get("assets") or []:
|
||||
name, size = a.get("name"), a.get("size")
|
||||
if isinstance(name, str) and isinstance(size, int):
|
||||
assets[name] = size
|
||||
return assets
|
||||
|
||||
|
||||
def latest_release_assets(repo: str, *, force_refresh: bool = False) -> Optional[dict[str, int]]:
|
||||
"""Newest-release asset sizes for `repo`, memoized (24h TTL). None when
|
||||
offline and never fetched. In-memory only -- a restart simply re-fetches."""
|
||||
if not repo:
|
||||
return None
|
||||
now = time.time()
|
||||
if not force_refresh:
|
||||
memo = _assets_memo.get(repo)
|
||||
if memo and now - memo[0] < _RELEASE_CACHE_TTL_SECONDS:
|
||||
return memo[1]
|
||||
assets = _fetch_latest_release_assets(repo)
|
||||
if assets is None:
|
||||
memo = _assets_memo.get(repo)
|
||||
return memo[1] if memo else None
|
||||
_assets_memo[repo] = (now, assets)
|
||||
return assets
|
||||
|
||||
|
||||
def update_download_size_bytes(
|
||||
marker: Optional[dict],
|
||||
latest_tag: Optional[str],
|
||||
repo: Optional[str],
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
) -> Optional[int]:
|
||||
"""Download size of the latest-release asset matching this host's installed
|
||||
bundle (same platform/arch/runtime suffix as the installed asset). None when
|
||||
there is no marker asset, the latest assets can't be read, or no match."""
|
||||
if not marker or not latest_tag or not repo:
|
||||
return None
|
||||
installed_asset = marker.get("asset")
|
||||
if not isinstance(installed_asset, str):
|
||||
return None
|
||||
# Tag-independent platform suffix: accept the fork's "app-*" bundles and the
|
||||
# upstream ggml-org "ubuntu-*"/"win-*" prebuilts ("windows" before "win").
|
||||
m = re.search(r"-((?:linux|ubuntu|windows|win|macos|darwin)-.*)$", installed_asset)
|
||||
if not m:
|
||||
return None
|
||||
suffix = m.group(1)
|
||||
# Upstream ubuntu/win assets live in the marker's binary_repo, not the fork
|
||||
# publish repo; try the publish repo first, then it.
|
||||
repos = [repo]
|
||||
binary_repo = marker.get("binary_repo")
|
||||
if isinstance(binary_repo, str) and binary_repo and binary_repo != repo:
|
||||
repos.append(binary_repo)
|
||||
want = f"app-{latest_tag}-{suffix}"
|
||||
for r in repos:
|
||||
assets = latest_release_assets(r, force_refresh = force_refresh)
|
||||
if not assets:
|
||||
continue
|
||||
if want in assets:
|
||||
return assets[want]
|
||||
# Tag formatting can vary (mix suffixes); fall back to the platform suffix.
|
||||
for name, size in assets.items():
|
||||
if name.endswith(suffix):
|
||||
return size
|
||||
return None
|
||||
|
||||
|
||||
def _parse_installed_at(value: object) -> Optional[datetime]:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
|
|
@ -313,6 +422,7 @@ def reset_caches(*, drop_disk: bool = False) -> None:
|
|||
open (off) instead of pointing at the just-replaced build."""
|
||||
_marker_cache.clear()
|
||||
_release_memo.clear()
|
||||
_assets_memo.clear()
|
||||
if drop_disk:
|
||||
import shutil
|
||||
|
||||
|
|
|
|||
|
|
@ -37,9 +37,11 @@ from utils.llama_cpp_freshness import (
|
|||
_INSTALL_MARKER_NAME,
|
||||
check_prebuilt_freshness,
|
||||
latest_published_release,
|
||||
latest_release_assets,
|
||||
parse_base_build,
|
||||
read_install_marker,
|
||||
reset_caches,
|
||||
update_download_size_bytes,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
|
@ -291,6 +293,18 @@ def _source_build_status(binary: str, *, force_refresh: bool) -> Optional[dict]:
|
|||
update_available = False
|
||||
# Display the mix tag when that's what makes it newer; otherwise the base.
|
||||
latest = release_tag if latest_is_mix else base_tag
|
||||
# Size of the resolved prebuilt, so source builds show it like the marker
|
||||
# path. Fails open to None (offline / asset absent from the release).
|
||||
update_size_bytes = None
|
||||
if update_available:
|
||||
asset_name = res.get("asset")
|
||||
if isinstance(asset_name, str) and asset_name:
|
||||
try:
|
||||
assets = latest_release_assets(res.get("repo"), force_refresh = force_refresh)
|
||||
if assets:
|
||||
update_size_bytes = assets.get(asset_name)
|
||||
except Exception as exc: # pragma: no cover - network defensive
|
||||
logger.debug("llama update: source-build size lookup failed", error = str(exc))
|
||||
with _job_lock:
|
||||
job = dict(_job)
|
||||
return {
|
||||
|
|
@ -303,6 +317,7 @@ def _source_build_status(binary: str, *, force_refresh: bool) -> Optional[dict]:
|
|||
"installed_at_utc": None,
|
||||
"age_days": None,
|
||||
"source_build": True,
|
||||
"update_size_bytes": update_size_bytes,
|
||||
"job": job,
|
||||
}
|
||||
|
||||
|
|
@ -345,6 +360,20 @@ def get_update_status(*, force_refresh: bool = False) -> dict:
|
|||
# (see llama_cpp_freshness.is_behind).
|
||||
update_available = bool(freshness.get("has_marker") and freshness.get("behind"))
|
||||
|
||||
# Size of the prebuilt that Update would download, for the banner. Only when
|
||||
# an update is offered; fails open to None (offline / no matching asset).
|
||||
update_size_bytes = None
|
||||
if update_available:
|
||||
try:
|
||||
update_size_bytes = update_download_size_bytes(
|
||||
marker,
|
||||
latest,
|
||||
freshness.get("published_repo") or repo,
|
||||
force_refresh = force_refresh,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - network defensive
|
||||
logger.debug("llama update: size lookup failed", error = str(exc))
|
||||
|
||||
with _job_lock:
|
||||
job = dict(_job)
|
||||
|
||||
|
|
@ -358,6 +387,7 @@ def get_update_status(*, force_refresh: bool = False) -> dict:
|
|||
"installed_at_utc": freshness.get("installed_at_utc"),
|
||||
"age_days": freshness.get("age_days"),
|
||||
"source_build": False,
|
||||
"update_size_bytes": update_size_bytes,
|
||||
"job": job,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,7 @@ import { useShowLlamaUpdateBanner } from "@/hooks/use-llama-update-pref";
|
|||
import { toast } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Download } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { type ReactElement, useEffect, useRef, useState } from "react";
|
||||
|
||||
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
|
||||
// Backend progress is coarse (5% steps, ~0.9 max) and the extract tail emits no
|
||||
// signal. Creep toward this cap so the bar keeps moving rather than freezing.
|
||||
const RUNNING_CAP = 0.95;
|
||||
|
|
@ -114,6 +111,12 @@ export function LlamaUpdateBanner({
|
|||
|
||||
const show =
|
||||
visible && status != null && (status.update_available || applying);
|
||||
const sizeBytes = status?.update_size_bytes ?? null;
|
||||
// Round to whole MB; these prebuilts are hundreds of MB.
|
||||
const sizeLabel =
|
||||
sizeBytes && sizeBytes > 0
|
||||
? `${Math.round(sizeBytes / (1024 * 1024))} MB`
|
||||
: null;
|
||||
const updateProgress = status?.job.progress ?? null;
|
||||
const jobSucceeded = status?.job.state === "success";
|
||||
// Drives the bar so it animates continuously; aria reports the real value.
|
||||
|
|
@ -123,113 +126,110 @@ export function LlamaUpdateBanner({
|
|||
jobSucceeded,
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
transition={{ duration: 0.35, ease: EASE_OUT_QUART }}
|
||||
className={cn(
|
||||
positioned
|
||||
? "fixed bottom-4 right-4 z-[9998] w-[calc(100vw-2rem)] max-w-[400px]"
|
||||
: "pointer-events-auto w-full",
|
||||
)}
|
||||
data-testid="llama-update-banner"
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-[24px] bg-white px-5 pb-4 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]">
|
||||
{applying ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="absolute top-2.5 right-3 flex size-6 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Dismiss llama.cpp update notification"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11 3L3 11M3 3l8 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 items-start gap-4 pr-6">
|
||||
<Download
|
||||
aria-hidden="true"
|
||||
className="mt-1 size-5 shrink-0 text-foreground"
|
||||
strokeWidth={1.75}
|
||||
// Render with no enter/exit animation. An opacity/transform transition (in or
|
||||
// out) promotes a GPU compositing layer whose creation or teardown can flash
|
||||
// for a frame on real displays, which reads as a flicker on appear and on
|
||||
// dismiss. A plain conditional mount appears and leaves cleanly.
|
||||
return show ? (
|
||||
<div
|
||||
className={cn(
|
||||
positioned
|
||||
? "fixed bottom-4 right-4 z-[9998] w-[calc(100vw-2rem)] max-w-[400px]"
|
||||
: "pointer-events-auto w-full",
|
||||
)}
|
||||
data-testid="llama-update-banner"
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-[24px] bg-white px-5 pb-4 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]">
|
||||
{applying ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="absolute top-2.5 right-3 flex size-6 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Dismiss llama.cpp update notification"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11 3L3 11M3 3l8 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="font-heading text-base font-medium text-foreground">
|
||||
{applying ? "Updating llama.cpp..." : "New llama.cpp update"}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{status?.installed_tag ?? "unknown"} →{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{status?.latest_tag ?? ""}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/70">
|
||||
No restart needed after update
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{applying ? (
|
||||
<div
|
||||
className="mb-1.5 mt-4 h-1 overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
aria-label="Updating llama.cpp"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={
|
||||
updateProgress != null
|
||||
? Math.round(updateProgress * 100)
|
||||
: Math.round(displayProgress * 100)
|
||||
}
|
||||
data-testid="llama-update-progress"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${Math.max(displayProgress * 100, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex flex-wrap items-center justify-end gap-x-1 gap-y-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-auto rounded-full px-3 py-2 text-[13px] font-medium text-foreground"
|
||||
onClick={snooze}
|
||||
data-testid="llama-update-snooze-button"
|
||||
>
|
||||
Remind me later
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
// -mr optically aligns the filled pill's edge with the card padding
|
||||
className="-mr-1 h-auto rounded-full px-3.5 py-2 text-[13px]"
|
||||
onClick={handleUpdate}
|
||||
data-testid="llama-update-button"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 items-start gap-4 pr-6">
|
||||
<Download
|
||||
aria-hidden="true"
|
||||
className="mt-1 size-5 shrink-0 text-foreground"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="font-heading text-base font-medium text-foreground">
|
||||
{applying ? "Updating llama.cpp..." : "New llama.cpp update"}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{status?.installed_tag ?? "unknown"} →{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{status?.latest_tag ?? ""}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/70">
|
||||
{sizeLabel ? `${sizeLabel} download · ` : ""}No restart needed
|
||||
after update
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
</div>
|
||||
|
||||
{applying ? (
|
||||
<div
|
||||
className="mb-1.5 mt-4 h-1 overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
aria-label="Updating llama.cpp"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={
|
||||
updateProgress != null
|
||||
? Math.round(updateProgress * 100)
|
||||
: Math.round(displayProgress * 100)
|
||||
}
|
||||
data-testid="llama-update-progress"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${Math.max(displayProgress * 100, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex flex-wrap items-center justify-end gap-x-1 gap-y-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-auto rounded-full px-3 py-2 text-[13px] font-medium text-foreground"
|
||||
onClick={snooze}
|
||||
data-testid="llama-update-snooze-button"
|
||||
>
|
||||
Remind me later
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
// -mr optically aligns the filled pill's edge with the card padding
|
||||
className="-mr-1 h-auto rounded-full px-3.5 py-2 text-[13px]"
|
||||
onClick={handleUpdate}
|
||||
data-testid="llama-update-button"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ export interface LlamaUpdateStatus {
|
|||
update_available: boolean;
|
||||
installed_tag: string | null;
|
||||
latest_tag: string | null;
|
||||
// Download size of the prebuilt Update would fetch, in bytes (null if unknown).
|
||||
update_size_bytes: number | null;
|
||||
job: LlamaUpdateJob;
|
||||
}
|
||||
|
||||
|
|
@ -43,6 +45,8 @@ function parseStatus(value: unknown): LlamaUpdateStatus | null {
|
|||
update_available: s.update_available === true,
|
||||
installed_tag: typeof s.installed_tag === "string" ? s.installed_tag : null,
|
||||
latest_tag: typeof s.latest_tag === "string" ? s.latest_tag : null,
|
||||
update_size_bytes:
|
||||
typeof s.update_size_bytes === "number" ? s.update_size_bytes : null,
|
||||
job: {
|
||||
state: (job.state as LlamaUpdateJob["state"]) ?? "idle",
|
||||
message: typeof job.message === "string" ? job.message : "",
|
||||
|
|
|
|||
|
|
@ -471,11 +471,15 @@ class TestInstallPythonStackSubprocessMock:
|
|||
# -- Normal Linux path (NO_TORCH=False, IS_MACOS=False, IS_WINDOWS=False) --
|
||||
|
||||
def test_normal_linux_includes_overrides(self):
|
||||
"""Normal Linux: overrides.txt IS called."""
|
||||
"""Normal Linux: the torchao override step IS called.
|
||||
|
||||
The override step installs a torch-matched torchao spec via
|
||||
--force-reinstall (uv: --reinstall), not overrides.txt directly.
|
||||
"""
|
||||
cmds = self._capture_install(no_torch = False, is_macos = False, is_windows = False)
|
||||
assert self._cmds_contain_file(
|
||||
cmds, "overrides.txt"
|
||||
), "overrides.txt should be called on normal Linux"
|
||||
assert any(
|
||||
"--reinstall" in cmd for cmd in cmds
|
||||
), "torchao override step (--reinstall) should be called on normal Linux"
|
||||
|
||||
def test_normal_linux_includes_triton(self):
|
||||
"""Normal Linux: triton-kernels.txt IS called."""
|
||||
|
|
|
|||
|
|
@ -1430,7 +1430,16 @@ with sync_playwright() as p:
|
|||
# Re-login through the UI with NEW2 so the browser has a valid
|
||||
# access token for the /api/shutdown call (the previous one
|
||||
# was invalidated by the CLI rotation above).
|
||||
page.goto(f"{BASE}/login")
|
||||
# The CLI rotation left a stale token, so the SPA auth guard can
|
||||
# client-side-redirect mid-navigation and abort this goto with
|
||||
# net::ERR_ABORTED. Resolve on domcontentloaded and tolerate the
|
||||
# abort; the password-field wait below confirms we reached /login.
|
||||
try:
|
||||
page.goto(f"{BASE}/login", wait_until = "domcontentloaded", timeout = 60_000)
|
||||
except Exception as exc:
|
||||
if "ERR_ABORTED" not in str(exc):
|
||||
raise
|
||||
info(f"goto /login aborted ({exc!r}); password-field wait will confirm /login")
|
||||
pw_field = page.locator("#password")
|
||||
pw_field.wait_for(state = "visible", timeout = 60_000)
|
||||
pw_field.fill(NEW2)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue