* add unsloth studio desktop app
* Fix review findings
- studio/src-tauri/tauri.conf.json: retarget updater to staging repo
(danielhanchen/unsloth-staging-2); switch to unslothai/unsloth on upstream merge.
- studio/src-tauri/linux/postremove.sh: drop the interactive read loop and the
/home/* iteration. Package maintainer scripts must stay non-interactive and
must not touch other users' data.
- studio/frontend/src/app/auth-guards.ts: honor tauriAutoAuth() boolean. Failed
auto-auth now redirects to /login; requireGuest/requirePasswordChangeFlow
only redirect to /chat when auth succeeds. The new early-return on failed
auth is intentional so the login / change-password flows remain reachable
when desktop auth is not yet established.
- studio/frontend/src/config/env.ts: keep fetched=false on health failure so
later calls retry instead of caching the client-side platform guess.
- studio/src-tauri/src/install.rs: pick the available system package manager
(apt-get, dnf, zypper, pacman); AppImage bundles run on non-Debian distros.
- studio/frontend/src/lib/open-link.ts + markdown-text/sources callers: return
boolean from openLink so callers only preventDefault on handled URLs; relative
hrefs now navigate natively.
- studio/frontend/src/features/settings/tabs/about-tab.tsx: fetch(apiUrl(...))
so the version request targets the backend port in desktop mode. The bare
/api/health predates the Tauri webview (blame: the earlier onboarding commit,
which ran with same-origin frontend/backend); in desktop mode the webview
origin is tauri://localhost so the bare path fails.
- install.ps1: gate the install_python_stack.py hotfix on a sentinel comment
instead of a content regex; append the sentinel after applying so reruns
are unambiguous.
- unsloth_cli/commands/studio.py _write_auth_secret: use the atomic mkstemp +
os.replace path on Windows too; chmod calls are wrapped in try/except OSError.
- studio/src-tauri/src/preflight.rs probe_existing_backends: fan out the health
probes concurrently; desktop-auth status still runs sequentially per candidate.
reqwest::Client is internally Arc-wrapped so the in-loop .clone() is a
refcount bump, not a deep clone; annotated inline.
- studio/src-tauri/src/preflight.rs run_cli_probe: wait() after kill() to reap
the child, matching probe_cli_capability.
- studio/src-tauri/src/process.rs + main.rs: add stop_backend_detached and use
it from the tray quit handler so the 5s graceful-wait does not block the
Tauri main loop. RunEvent::Exit keeps the synchronous safety-net call.
- studio/backend/main.py: drop the permissive localhost CORS regex in
api-only mode; the explicit allow_origins list is sufficient.
- .github/workflows/release-desktop.yml: drop max-parallel: 1 so platform
builds run in parallel, and lift releaseBody to an env var so the three
tauri-action invocations share one source of truth.
* Fix review findings (loop 2)
- studio/backend/auth/storage.py update_password: clear_desktop_secret()
alongside clear_bootstrap_password() so rotating the admin password
also revokes any previously provisioned .desktop_secret. Without this,
an old local desktop credential keeps minting fresh admin tokens via
/api/auth/desktop-login after a password rotation.
- studio/src-tauri/src/desktop_auth.rs provision_desktop_auth: wrap
cmd.output().await in tokio::time::timeout(30s). DESKTOP_AUTH_LOCK is
held across the whole desktop_auth flow, and previously a hanging
`unsloth studio provision-desktop-auth` subprocess would pin the lock
indefinitely and freeze every subsequent desktop_auth call.
* Add review tests
* Consolidate review tests
Merge review-added tests into the existing studio/backend/tests/test_desktop_auth.py
(the PR's authoritative desktop-auth test file). Drops three scaffolding files under
tests/python/ in favor of five focused tests next to the tests they extend:
- test_update_password_clears_desktop_secret (runtime)
- test_update_password_on_unknown_user_leaves_desktop_secret_intact (runtime)
- test_cli_provisioning_delegates_to_storage_create_desktop_secret (source-level)
- test_cli_connect_auth_db_reads_storage_db_path (source-level)
- test_desktop_auth_provision_has_bounded_timeout (Rust source-level)
* Revert auth-guards.ts Tauri branches to unconditional form
The review loop on PR 5144 introduced a regression: the isTauri branch of
requireAuth redirected to /login when tauriAutoAuth() returned false, and
requireGuest / requirePasswordChangeFlow silently fell through on the same
condition. The Tauri desktop app authenticates via a local auto-generated
secret; it must never surface /login or /change-password to the user. A
failed auto-auth should let the startup layer retry, not expose a password
form.
Restore the three Tauri branches to the author's original unconditional
form (requireAuth: return; requireGuest / requirePasswordChangeFlow: throw
redirect({to: '/chat'})). Keep the rest of the review fixes -- the
apiUrl() fetch wrapping, authRedirect helper, and fetchAuthStatus refactor
are all legitimate improvements and are preserved.
* Revert release-desktop.yml to author's version
The review loop's workflow-file tweaks (drop max-parallel: 1, lift releaseBody
to an env var) are cosmetic. OAuth tokens cannot push workflow-file changes,
and fine-grained PATs cannot honor maintainerCanModify on a third-party fork.
Reverting the workflow file to wasimysaid's version lets the push go through
without needing a classic PAT with both repo and workflow scopes.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Daniel Han <unslothai@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
287 lines
9.6 KiB
Python
287 lines
9.6 KiB
Python
# 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 subprocess
|
|
from typing import Any, Optional
|
|
|
|
from loggers import get_logger
|
|
|
|
from utils.subprocess_compat import (
|
|
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
|
|
)
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def _parse_smi_value(raw: str):
|
|
raw = raw.strip()
|
|
if not raw or raw == "[N/A]":
|
|
return None
|
|
try:
|
|
return float(raw)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
def _build_gpu_metrics(
|
|
vram_used_mb,
|
|
vram_total_mb,
|
|
power_draw,
|
|
power_limit,
|
|
**extra,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
**extra,
|
|
"vram_used_gb": round(vram_used_mb / 1024, 2)
|
|
if vram_used_mb is not None
|
|
else None,
|
|
"vram_total_gb": round(vram_total_mb / 1024, 2)
|
|
if vram_total_mb is not None
|
|
else None,
|
|
"vram_utilization_pct": round((vram_used_mb / vram_total_mb) * 100, 1)
|
|
if vram_used_mb is not None and vram_total_mb and vram_total_mb > 0
|
|
else None,
|
|
"power_draw_w": power_draw,
|
|
"power_limit_w": power_limit,
|
|
"power_utilization_pct": round((power_draw / power_limit) * 100, 1)
|
|
if power_draw is not None and power_limit and power_limit > 0
|
|
else None,
|
|
}
|
|
|
|
|
|
def _visible_ordinal_map(
|
|
parent_visible_ids: Optional[list[int]],
|
|
) -> Optional[dict[int, int]]:
|
|
if parent_visible_ids is None:
|
|
return None
|
|
return {gpu_id: ordinal for ordinal, gpu_id in enumerate(parent_visible_ids)}
|
|
|
|
|
|
def get_physical_gpu_count() -> Optional[int]:
|
|
"""Return physical GPU count via nvidia-smi, or None on failure."""
|
|
try:
|
|
result = subprocess.run(
|
|
["nvidia-smi", "-L"],
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 5,
|
|
**_windows_hidden_subprocess_kwargs(),
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
return len(result.stdout.strip().splitlines())
|
|
logger.warning(
|
|
"nvidia-smi -L returned code %d; caller should fall back to torch",
|
|
result.returncode,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("nvidia-smi -L failed: %s; caller should fall back to torch", e)
|
|
return None
|
|
|
|
|
|
def get_primary_gpu_utilization() -> dict[str, Any]:
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"nvidia-smi",
|
|
"--query-gpu=utilization.gpu,temperature.gpu,"
|
|
"memory.used,memory.total,power.draw,power.limit",
|
|
"--format=csv,noheader,nounits",
|
|
],
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 5,
|
|
**_windows_hidden_subprocess_kwargs(),
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as e:
|
|
logger.warning("nvidia-smi query failed in get_primary_gpu_utilization: %s", e)
|
|
return {"available": False}
|
|
if result.returncode != 0 or not result.stdout.strip():
|
|
return {"available": False}
|
|
|
|
first_line = result.stdout.strip().splitlines()[0]
|
|
parts = [p.strip() for p in first_line.split(",")]
|
|
if len(parts) < 6:
|
|
return {"available": False}
|
|
|
|
return _build_gpu_metrics(
|
|
vram_used_mb = _parse_smi_value(parts[2]),
|
|
vram_total_mb = _parse_smi_value(parts[3]),
|
|
power_draw = _parse_smi_value(parts[4]),
|
|
power_limit = _parse_smi_value(parts[5]),
|
|
available = True,
|
|
gpu_utilization_pct = _parse_smi_value(parts[0]),
|
|
temperature_c = _parse_smi_value(parts[1]),
|
|
)
|
|
|
|
|
|
def get_visible_gpu_utilization(
|
|
parent_visible_ids: Optional[list[int]],
|
|
parent_cuda_visible_devices: Optional[str] = None,
|
|
) -> dict[str, Any]:
|
|
# When parent_visible_ids is None (UUID/MIG mask), we cannot safely
|
|
# map nvidia-smi rows to the process's visible devices. Return empty
|
|
# instead of exposing all physical GPUs.
|
|
if parent_visible_ids is None:
|
|
return {
|
|
"available": False,
|
|
"backend_cuda_visible_devices": parent_cuda_visible_devices,
|
|
"parent_visible_gpu_ids": [],
|
|
"devices": [],
|
|
"index_kind": "unresolved",
|
|
}
|
|
visible_ordinals = _visible_ordinal_map(parent_visible_ids)
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"nvidia-smi",
|
|
"--query-gpu=index,utilization.gpu,temperature.gpu,"
|
|
"memory.used,memory.total,power.draw,power.limit",
|
|
"--format=csv,noheader,nounits",
|
|
],
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 5,
|
|
**_windows_hidden_subprocess_kwargs(),
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as e:
|
|
logger.warning("nvidia-smi query failed in get_visible_gpu_utilization: %s", e)
|
|
return {
|
|
"available": False,
|
|
"backend_cuda_visible_devices": parent_cuda_visible_devices,
|
|
"parent_visible_gpu_ids": parent_visible_ids or [],
|
|
"devices": [],
|
|
"index_kind": "physical",
|
|
}
|
|
if result.returncode != 0 or not result.stdout.strip():
|
|
return {
|
|
"available": False,
|
|
"backend_cuda_visible_devices": parent_cuda_visible_devices,
|
|
"parent_visible_gpu_ids": parent_visible_ids or [],
|
|
"devices": [],
|
|
"index_kind": "physical",
|
|
}
|
|
|
|
devices = []
|
|
for line in result.stdout.strip().splitlines():
|
|
parts = [p.strip() for p in line.split(",")]
|
|
if len(parts) < 7:
|
|
continue
|
|
|
|
try:
|
|
idx = int(parts[0])
|
|
except (ValueError, TypeError):
|
|
continue
|
|
|
|
if visible_ordinals is not None and idx not in visible_ordinals:
|
|
continue
|
|
|
|
devices.append(
|
|
_build_gpu_metrics(
|
|
vram_used_mb = _parse_smi_value(parts[3]),
|
|
vram_total_mb = _parse_smi_value(parts[4]),
|
|
power_draw = _parse_smi_value(parts[5]),
|
|
power_limit = _parse_smi_value(parts[6]),
|
|
index = idx,
|
|
index_kind = "physical",
|
|
visible_ordinal = (
|
|
visible_ordinals[idx]
|
|
if visible_ordinals is not None
|
|
else len(devices)
|
|
),
|
|
gpu_utilization_pct = _parse_smi_value(parts[1]),
|
|
temperature_c = _parse_smi_value(parts[2]),
|
|
)
|
|
)
|
|
|
|
return {
|
|
"available": len(devices) > 0,
|
|
"backend_cuda_visible_devices": parent_cuda_visible_devices,
|
|
"parent_visible_gpu_ids": parent_visible_ids or [],
|
|
"devices": devices,
|
|
"index_kind": "physical",
|
|
}
|
|
|
|
|
|
def get_backend_visible_gpu_info(
|
|
parent_visible_ids: Optional[list[int]],
|
|
backend_cuda_visible_devices: Optional[str],
|
|
) -> dict[str, Any]:
|
|
# When parent_visible_ids is None (UUID/MIG mask), we cannot safely
|
|
# map nvidia-smi rows to the process's visible devices.
|
|
if parent_visible_ids is None:
|
|
return {
|
|
"available": False,
|
|
"backend_cuda_visible_devices": backend_cuda_visible_devices,
|
|
"parent_visible_gpu_ids": [],
|
|
"devices": [],
|
|
"index_kind": "unresolved",
|
|
}
|
|
visible_ordinals = _visible_ordinal_map(parent_visible_ids)
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"nvidia-smi",
|
|
"--query-gpu=index,name,memory.total",
|
|
"--format=csv,noheader,nounits",
|
|
],
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 10,
|
|
**_windows_hidden_subprocess_kwargs(),
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as e:
|
|
logger.warning("nvidia-smi query failed in get_backend_visible_gpu_info: %s", e)
|
|
return {
|
|
"available": False,
|
|
"backend_cuda_visible_devices": backend_cuda_visible_devices,
|
|
"parent_visible_gpu_ids": parent_visible_ids or [],
|
|
"devices": [],
|
|
"index_kind": "physical",
|
|
}
|
|
if result.returncode != 0:
|
|
return {
|
|
"available": False,
|
|
"backend_cuda_visible_devices": backend_cuda_visible_devices,
|
|
"parent_visible_gpu_ids": parent_visible_ids or [],
|
|
"devices": [],
|
|
"index_kind": "physical",
|
|
}
|
|
|
|
devices = []
|
|
for line in result.stdout.strip().splitlines():
|
|
parts = [p.strip() for p in line.split(",")]
|
|
if len(parts) < 3:
|
|
continue
|
|
try:
|
|
idx = int(parts[0])
|
|
except (ValueError, TypeError):
|
|
continue
|
|
if visible_ordinals is not None and idx not in visible_ordinals:
|
|
continue
|
|
# Use split with limit to handle GPU names containing commas
|
|
name = parts[1] if len(parts) == 3 else ", ".join(parts[1:-1])
|
|
try:
|
|
mem_total_mb = int(parts[-1])
|
|
except (ValueError, TypeError):
|
|
continue
|
|
devices.append(
|
|
{
|
|
"index": idx,
|
|
"index_kind": "physical",
|
|
"visible_ordinal": (
|
|
visible_ordinals[idx]
|
|
if visible_ordinals is not None
|
|
else len(devices)
|
|
),
|
|
"name": name,
|
|
"memory_total_gb": round(mem_total_mb / 1024, 2),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"available": len(devices) > 0,
|
|
"backend_cuda_visible_devices": backend_cuda_visible_devices,
|
|
"parent_visible_gpu_ids": parent_visible_ids or [],
|
|
"devices": devices,
|
|
"index_kind": "physical",
|
|
}
|