diff --git a/studio/backend/main.py b/studio/backend/main.py index 2022074c08..2435efba21 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -250,7 +250,7 @@ if os.getenv("ENVIRONMENT_TYPE", "production") == "production": # warnings.filterwarnings("ignore", category=DeprecationWarning) # warnings.filterwarnings("ignore", module="triton.*") -from fastapi import Depends, FastAPI, HTTPException, Request +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 @@ -967,17 +967,40 @@ async def get_gpu_visibility(current_subject: str = Depends(get_current_subject) @app.get("/api/system/hardware") -async def get_hardware_info(current_subject: str = Depends(get_current_subject)): +def get_hardware_info( + include_details: bool = Query(False), current_subject: str = Depends(get_current_subject) +): """Return GPU name, total VRAM, and key ML package versions. Gated behind auth alongside /api/system -- same fingerprinting concern. /api/system/gpu-visibility is also auth-gated. + + ``include_details`` is for About/diagnostics. The default response stays + cheap for callers that only need the primary GPU summary, like training + method auto-selection. Sync def (not async): hardware/detail probes can + shell out, and FastAPI runs sync endpoints in a threadpool. """ from utils.hardware import get_gpu_summary, get_package_versions - return { + + body = { "gpu": get_gpu_summary(), "versions": get_package_versions(), } + if include_details: + from utils.llama_cpp_update import get_installed_llama_version + + # All backend-visible GPUs (respects CUDA_VISIBLE_DEVICES), so multi-GPU + # hosts list every device -- get_gpu_summary alone reports only the primary. + # Sort by visible_ordinal: the nvidia-smi path returns rows in physical order, + # so under a reordering CUDA_VISIBLE_DEVICES (e.g. "5,3") labeling by array + # index would otherwise disagree with the GPU 0/1 the backend actually sees. + devices = get_backend_visible_gpu_info().get("devices", []) + body["gpus"] = [ + {"name": d.get("name"), "vram_total_gb": d.get("memory_total_gb")} + for d in sorted(devices, key = lambda d: d.get("visible_ordinal", 0)) + ] + body["llama_cpp"] = get_installed_llama_version() + return body # ============ Serve Frontend (Optional) ============ diff --git a/studio/backend/tests/test_llama_cpp_update.py b/studio/backend/tests/test_llama_cpp_update.py index 326b3dc6aa..c42c3e8a79 100644 --- a/studio/backend/tests/test_llama_cpp_update.py +++ b/studio/backend/tests/test_llama_cpp_update.py @@ -293,6 +293,33 @@ def test_status_source_build_skips_probe_while_job_runs(monkeypatch, tmp_path): assert probes == {"resolve": 0, "version": 0} +def test_installed_version_skips_probe_while_job_runs(monkeypatch, tmp_path): + # Markerless build: get_installed_llama_version falls back to exec'ing + # `llama-server --version`. While the updater swaps the tree that exec can + # fail the installer's os.replace on Windows, so the About-panel probe must + # be skipped (return None) exactly like get_update_status's source probe. + binary = tmp_path / "build" / "bin" / "llama-server" + binary.parent.mkdir(parents = True) + binary.write_text("stub") # markerless: no UNSLOTH_PREBUILT_INFO.json + monkeypatch.setattr(upd, "_find_binary", lambda: str(binary)) + probed = {"n": 0} + + def _count_version(b): + probed["n"] += 1 + return 9585 + + monkeypatch.setattr(upd, "_installed_build_number", _count_version) + + with upd._job_lock: + upd._job["state"] = upd._JOB_RUNNING + assert upd.get_installed_llama_version() is None + assert probed["n"] == 0 # never exec'd the binary mid-swap + + upd._reset_job_for_tests() # back to idle -> probe runs + assert upd.get_installed_llama_version() == "b9585" + assert probed["n"] == 1 + + def test_status_update_available(monkeypatch, tmp_path): binary = _write_install(tmp_path, "b9493") monkeypatch.setattr(upd, "_find_binary", lambda: binary) diff --git a/studio/backend/utils/llama_cpp_update.py b/studio/backend/utils/llama_cpp_update.py index 3518247d7e..c2c6c67432 100644 --- a/studio/backend/utils/llama_cpp_update.py +++ b/studio/backend/utils/llama_cpp_update.py @@ -180,6 +180,40 @@ def _installed_build_number(binary: Optional[str]) -> Optional[int]: return n if n > 1 else None +def get_installed_llama_version() -> Optional[str]: + """Display string for the active llama.cpp install (e.g. 'b9585' or + 'b9601-mix-a0e2906'), or None. + + Prefers the install marker's release_tag -- the full unsloth release + identity, the same field the update banner compares as installed (see + #6219) -- so a 'b9601-mix-a0e2906' build reads back in full rather than + collapsing to its base 'b9601'. The marker's bare ``tag`` is only the + upstream llama.cpp build (no '-mix-' suffix), so it's the fallback. + Last resort is ``b`` parsed from ``llama-server --version`` for + source/custom builds that have no marker. + + Lightweight: reads the local marker and at most runs ``--version``. Does no + network or release-freshness work (unlike get_update_status), so it is safe + to call from latency-sensitive paths like the About panel. + """ + binary = _find_binary() + marker = read_install_marker(binary) + if marker: + tag = marker.get("release_tag") or marker.get("tag") + if tag: + return tag + # Markerless/source build: the fallback execs ``llama-server --version``. + # Skip it while an update is swapping the tree -- on Windows that exec can + # make the installer's os.replace fail (the same race get_update_status's + # source-build probe guards against). The panel just omits the row. + with _job_lock: + job_running = _job["state"] == _JOB_RUNNING + if job_running: + return None + n = _installed_build_number(binary) + return f"b{n}" if n is not None else None + + def _is_under(path: Path, root: Path) -> bool: try: p, r = path.resolve(), root.resolve() diff --git a/studio/frontend/src/features/settings/components/studio-version-section.tsx b/studio/frontend/src/features/settings/components/studio-version-section.tsx index 9ede7cd35d..9b0c513cbf 100644 --- a/studio/frontend/src/features/settings/components/studio-version-section.tsx +++ b/studio/frontend/src/features/settings/components/studio-version-section.tsx @@ -35,8 +35,14 @@ async function fetchStudioVersions(): Promise<{ } } -// Shared "Unsloth" version block, shown in both General and About. -export function StudioVersionSection() { +// Shared "Unsloth" version block, shown in both General and About. The About +// tab passes llamaCppVersion to surface the installed llama.cpp build alongside +// the version rows; General omits it, so the row only shows on About. +export function StudioVersionSection({ + llamaCppVersion, +}: { + llamaCppVersion?: string | null; +} = {}) { const t = useT(); const [packageVersion, setPackageVersion] = useState("dev"); const [studioVersion, setStudioVersion] = useState("dev"); @@ -65,6 +71,13 @@ export function StudioVersionSection() { {packageVersion} + {llamaCppVersion ? ( + + + {llamaCppVersion} + + + ) : null} ); } diff --git a/studio/frontend/src/features/settings/tabs/about-tab.tsx b/studio/frontend/src/features/settings/tabs/about-tab.tsx index 15684d9b3b..2394e2ad92 100644 --- a/studio/frontend/src/features/settings/tabs/about-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/about-tab.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { usePlatformStore } from "@/config/env"; import { getAuthToken } from "@/features/auth"; import { removeTrainingUnloadGuard } from "@/features/training"; +import { useHardwareInfo } from "@/hooks/use-hardware-info"; import { useT } from "@/i18n"; import { apiUrl, isTauri } from "@/lib/api-base"; import { @@ -74,6 +75,7 @@ export function AboutTab() { const t = useT(); const deviceType = usePlatformStore((s) => s.deviceType); const defaultShell = deviceType === "windows" ? "windows" : "unix"; + const hw = useHardwareInfo(); const [shutdownOpen, setShutdownOpen] = useState(false); const [installSource, setInstallSource] = useState< UpdateInstallSource | "loading" @@ -104,7 +106,9 @@ export function AboutTab() {

- + {/* llama.cpp row lives in the shared version section so it sits with the + Unsloth/Package rows; the prop keeps it About-only (General passes none). */} +
@@ -116,6 +120,40 @@ export function AboutTab() {
+ {hw.gpus.length > 0 || hw.cuda || hw.rocm ? ( + + {hw.gpus.map((gpu, i) => ( + 1 + ? `${t("settings.about.gpu")} ${i}` + : t("settings.about.gpu") + } + > + + {gpu.name ?? "—"} + {gpu.vramTotalGb != null + ? ` · ${Math.round(gpu.vramTotalGb)} GB` + : ""} + + + ))} + {hw.cuda || hw.rocm ? ( + + + {hw.cuda ?? hw.rocm} + + + ) : null} + + ) : null} + | null = null; +let cacheGeneration = 0; +const listeners = new Set<(info: HardwareInfo) => void>(); + +function notifyHardwareInfo(info: HardwareInfo) { + listeners.forEach((listener) => listener(info)); +} + +export function invalidateHardwareInfo() { + cacheGeneration += 1; + cached = null; + fetchPromise = null; +} + +export async function refreshHardwareInfo(): Promise { + invalidateHardwareInfo(); + return fetchOnce(); +} async function fetchOnce(): Promise { if (cached) return cached; if (fetchPromise) return fetchPromise; + const generation = cacheGeneration; fetchPromise = (async () => { try { - const res = await authFetch("/api/system/hardware"); + const res = await authFetch("/api/system/hardware?include_details=true"); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const info: HardwareInfo = { gpuName: data?.gpu?.gpu_name ?? null, vramTotalGb: data?.gpu?.vram_total_gb ?? null, vramFreeGb: data?.gpu?.vram_free_gb ?? null, + gpus: Array.isArray(data?.gpus) + ? data.gpus.map((g: ApiGpu) => ({ + name: g?.name ?? null, + vramTotalGb: g?.vram_total_gb ?? null, + })) + : [], torch: data?.versions?.torch ?? null, cuda: data?.versions?.cuda ?? null, + rocm: data?.versions?.rocm ?? null, transformers: data?.versions?.transformers ?? null, unsloth: data?.versions?.unsloth ?? null, + llamaCpp: data?.llama_cpp ?? null, }; - cached = info; - return info; + if (generation === cacheGeneration) { + cached = info; + notifyHardwareInfo(info); + return info; + } + return cached ?? DEFAULT; } catch { // Reset so subsequent calls retry (e.g. backend wasn't ready). - fetchPromise = null; + if (generation === cacheGeneration) fetchPromise = null; return DEFAULT; } })(); @@ -66,13 +112,17 @@ export function useHardwareInfo(): HardwareInfo { const [info, setInfo] = useState(cached ?? DEFAULT); useEffect(() => { - if (cached) return; - let cancelled = false; - fetchOnce().then((hw) => { + const listener = (hw: HardwareInfo) => { if (!cancelled) setInfo(hw); - }); - return () => { cancelled = true; }; + }; + + listeners.add(listener); + if (!cached) fetchOnce().then(listener); + return () => { + cancelled = true; + listeners.delete(listener); + }; }, []); return info; diff --git a/studio/frontend/src/hooks/use-llama-update-check.ts b/studio/frontend/src/hooks/use-llama-update-check.ts index 73e12f9bff..5fcacab521 100644 --- a/studio/frontend/src/hooks/use-llama-update-check.ts +++ b/studio/frontend/src/hooks/use-llama-update-check.ts @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { authFetch, getAuthToken } from "@/features/auth"; +import { refreshHardwareInfo } from "@/hooks/use-hardware-info"; import { useCallback, useEffect, useRef, useState } from "react"; // First check shortly after load, then re-surface as an hourly reminder. The @@ -120,6 +121,7 @@ export function useLlamaUpdateCheck({ setApplying(false); if (s.job.state === "success") { setVisible(false); + void refreshHardwareInfo(); onDone?.({ ok: true, tag: s.job.to_tag }); } else if (s.job.state === "error") { // Leave the banner up so the user can retry; clearing applying drops diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts index 08feda7300..9b32c22dde 100644 --- a/studio/frontend/src/i18n/locales/en.ts +++ b/studio/frontend/src/i18n/locales/en.ts @@ -329,6 +329,11 @@ export const en = { "Docs, release notes, feedback, and build info.", studioVersion: "Unsloth Version", packageVersion: "Package Version", + llamaCppVersion: "llama.cpp Version", + hardware: "Hardware", + gpu: "GPU", + cuda: "CUDA", + rocm: "ROCm", updates: "Update", help: "Help", documentation: "Documentation", diff --git a/studio/frontend/src/i18n/locales/zh-CN.ts b/studio/frontend/src/i18n/locales/zh-CN.ts index 9141a4c104..c98ff794bd 100644 --- a/studio/frontend/src/i18n/locales/zh-CN.ts +++ b/studio/frontend/src/i18n/locales/zh-CN.ts @@ -301,6 +301,11 @@ export const zhCN = { description: "文档、发布说明、反馈和 Unsloth 构建信息。", studioVersion: "Unsloth 版本", packageVersion: "包版本", + llamaCppVersion: "llama.cpp 版本", + hardware: "硬件", + gpu: "GPU", + cuda: "CUDA", + rocm: "ROCm", updates: "更新", help: "帮助", documentation: "文档",