Compare commits
13 commits
main
...
feature/up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba0dfeab63 | ||
|
|
f01affa6a3 | ||
|
|
9a7d080e94 | ||
|
|
3ccca16aac | ||
|
|
6b843a8e8e | ||
|
|
7bca7bf0db | ||
|
|
5301c5daf0 | ||
|
|
2bcafd1c25 | ||
|
|
a1c7b95a5a | ||
|
|
67b09be3f0 | ||
|
|
a681c778bd | ||
|
|
42b60063f6 | ||
|
|
7eda1ba5a8 |
9 changed files with 460 additions and 19 deletions
9
UNSLOTH_UPDATE_DETAILS.json
Normal file
9
UNSLOTH_UPDATE_DETAILS.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"schema_version": 1,
|
||||
"unsloth_source": "pypi",
|
||||
"unsloth_github_ref": "main",
|
||||
"llama_cpp_source": "unslothai",
|
||||
"llama_cpp_tag": "latest",
|
||||
"CRITICAL_TIME": null,
|
||||
"announcement": null
|
||||
}
|
||||
|
|
@ -118,6 +118,25 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
threading.Thread(target = _precache, daemon = True).start()
|
||||
|
||||
# Fetch remote update manifest in the background and print a terminal
|
||||
# banner when a critical update or announcement is present.
|
||||
def _check_updates():
|
||||
try:
|
||||
from utils.update_check import fetch_and_cache_update_status
|
||||
|
||||
status = fetch_and_cache_update_status()
|
||||
if status.critical:
|
||||
print("\n" + "=" * 60)
|
||||
print("CRITICAL UPDATE AVAILABLE")
|
||||
print(" Run `unsloth studio update` then restart Studio.")
|
||||
print("=" * 60 + "\n")
|
||||
elif status.announcement_message:
|
||||
print(f"\n [Update] {status.announcement_message}\n")
|
||||
except Exception:
|
||||
pass # non-critical
|
||||
|
||||
threading.Thread(target = _check_updates, daemon = True).start()
|
||||
|
||||
if storage.ensure_default_admin():
|
||||
bootstrap_pw = storage.get_bootstrap_password()
|
||||
app.state.bootstrap_password = bootstrap_pw
|
||||
|
|
@ -203,6 +222,27 @@ async def health_check():
|
|||
}
|
||||
|
||||
|
||||
@app.get("/api/update-check")
|
||||
async def update_check():
|
||||
"""Return cached update status from the remote manifest (unauthenticated)."""
|
||||
from utils.update_check import fetch_and_cache_update_status, get_update_status
|
||||
|
||||
s = get_update_status()
|
||||
if not s.manifest_fetched:
|
||||
# Background thread may not have finished yet -- fetch inline.
|
||||
# Run in a thread to avoid blocking the async event loop.
|
||||
import asyncio
|
||||
|
||||
s = await asyncio.to_thread(fetch_and_cache_update_status)
|
||||
return {
|
||||
"critical": s.critical,
|
||||
"announcement_badge": s.announcement_badge,
|
||||
"announcement_message": s.announcement_message,
|
||||
"announcement_url": s.announcement_url,
|
||||
"manifest_fetched": s.manifest_fetched,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/shutdown")
|
||||
async def shutdown_server(
|
||||
request: Request,
|
||||
|
|
|
|||
108
studio/backend/utils/update_check.py
Normal file
108
studio/backend/utils/update_check.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Remote manifest fetch and update-status cache for Unsloth Studio.
|
||||
|
||||
Uses only stdlib (urllib.request, json, time, calendar) so it can run
|
||||
without any third-party dependencies. The module exposes two public
|
||||
functions:
|
||||
|
||||
fetch_and_cache_update_status() -- fetches the manifest, reads the
|
||||
local UNSLOTH_STUDIO_INFO.json, compares CRITICAL_TIME, and caches
|
||||
the result at module level.
|
||||
|
||||
get_update_status() -- returns the cached UpdateStatus (or defaults
|
||||
when the fetch has not completed yet).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_MANIFEST_URL = (
|
||||
"https://raw.githubusercontent.com/unslothai/unsloth/main/"
|
||||
"UNSLOTH_UPDATE_DETAILS.json"
|
||||
)
|
||||
_STUDIO_INFO_PATH = Path.home() / ".unsloth" / "studio" / "UNSLOTH_STUDIO_INFO.json"
|
||||
_FETCH_TIMEOUT = 8 # seconds
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateStatus:
|
||||
critical: bool = False
|
||||
announcement_badge: Optional[str] = None
|
||||
announcement_message: Optional[str] = None
|
||||
announcement_url: Optional[str] = None
|
||||
manifest_fetched: bool = False
|
||||
|
||||
|
||||
_cached_status: UpdateStatus = UpdateStatus()
|
||||
|
||||
|
||||
def _parse_iso_utc(s: str) -> float:
|
||||
"""Parse an ISO-8601 UTC string (ending in Z) to a Unix timestamp."""
|
||||
s = s.strip().rstrip("Z")
|
||||
try:
|
||||
t = time.strptime(s, "%Y-%m-%dT%H:%M:%S")
|
||||
except ValueError:
|
||||
t = time.strptime(s[:19], "%Y-%m-%dT%H:%M:%S")
|
||||
return float(calendar.timegm(t))
|
||||
|
||||
|
||||
def fetch_and_cache_update_status() -> UpdateStatus:
|
||||
"""Fetch the remote manifest, compare with local info, and cache."""
|
||||
global _cached_status
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(_MANIFEST_URL, method = "GET")
|
||||
with urllib.request.urlopen(req, timeout = _FETCH_TIMEOUT) as resp:
|
||||
manifest = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception:
|
||||
_log.debug("manifest fetch failed", exc_info = True)
|
||||
return _cached_status
|
||||
|
||||
status = UpdateStatus(manifest_fetched = True)
|
||||
|
||||
# -- Critical time check --
|
||||
critical_time_str = manifest.get("CRITICAL_TIME")
|
||||
if critical_time_str:
|
||||
try:
|
||||
critical_ts = _parse_iso_utc(critical_time_str)
|
||||
installed_ts = 0.0
|
||||
if _STUDIO_INFO_PATH.is_file():
|
||||
try:
|
||||
info = json.loads(_STUDIO_INFO_PATH.read_text(encoding = "utf-8"))
|
||||
installed_ts = _parse_iso_utc(info.get("installed_at_utc", ""))
|
||||
except Exception:
|
||||
_log.debug("failed to read studio info", exc_info = True)
|
||||
if installed_ts < critical_ts:
|
||||
status.critical = True
|
||||
except Exception:
|
||||
_log.debug("critical time comparison failed", exc_info = True)
|
||||
|
||||
# -- Announcement --
|
||||
announcement = manifest.get("announcement")
|
||||
if isinstance(announcement, dict):
|
||||
status.announcement_badge = announcement.get("badge") or None
|
||||
status.announcement_message = announcement.get("message") or None
|
||||
_url = announcement.get("url") or None
|
||||
# Only allow http/https URLs to prevent javascript: or data: injection.
|
||||
if _url and _url.startswith(("https://", "http://")):
|
||||
status.announcement_url = _url
|
||||
|
||||
_cached_status = status
|
||||
return status
|
||||
|
||||
|
||||
def get_update_status() -> UpdateStatus:
|
||||
"""Return the cached update status (safe to call before fetch completes)."""
|
||||
return _cached_status
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { useUpdateCheck } from "@/hooks/use-update-check";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import {
|
||||
Outlet,
|
||||
|
|
@ -33,12 +34,27 @@ export const Route = createRootRoute({
|
|||
|
||||
const HIDDEN_NAVBAR_ROUTES = ["/onboarding", "/login", "/change-password"];
|
||||
|
||||
function CriticalUpdateBanner() {
|
||||
const { critical } = useUpdateCheck();
|
||||
if (!critical) return null;
|
||||
return (
|
||||
<div className="w-full bg-amber-500 px-4 py-2 text-center text-sm font-medium text-white dark:bg-amber-600">
|
||||
A critical update is available. Run{" "}
|
||||
<code className="rounded bg-amber-600/50 px-1 py-0.5 text-xs dark:bg-amber-700/50">
|
||||
unsloth studio update
|
||||
</code>{" "}
|
||||
then restart Studio.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayout() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
const hideNavbar = HIDDEN_NAVBAR_ROUTES.includes(pathname);
|
||||
|
||||
return (
|
||||
<AppProvider>
|
||||
{!hideNavbar && <CriticalUpdateBanner />}
|
||||
{!hideNavbar && <Navbar />}
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { useTrainingRuntimeStore } from "@/features/training";
|
||||
import { useUpdateCheck } from "@/hooks/use-update-check";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { Link, useRouterState } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
|
|
@ -127,10 +128,14 @@ function UpdateStudioInstructions({
|
|||
className,
|
||||
defaultShell,
|
||||
showTitle = true,
|
||||
announcementMessage,
|
||||
announcementUrl,
|
||||
}: {
|
||||
className?: string;
|
||||
defaultShell: UpdateShell;
|
||||
showTitle?: boolean;
|
||||
announcementMessage?: string | null;
|
||||
announcementUrl?: string | null;
|
||||
}): ReactElement {
|
||||
const [shell, setShell] = useState<UpdateShell>(defaultShell);
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
|
@ -148,6 +153,17 @@ function UpdateStudioInstructions({
|
|||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3", className)}>
|
||||
{announcementMessage ? (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200">
|
||||
{announcementUrl ? (
|
||||
<a href={announcementUrl} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
{announcementMessage}
|
||||
</a>
|
||||
) : (
|
||||
announcementMessage
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3",
|
||||
|
|
@ -247,6 +263,11 @@ export function Navbar() {
|
|||
const deviceType = usePlatformStore((s) => s.deviceType);
|
||||
const chatOnly = usePlatformStore((s) => s.isChatOnly());
|
||||
const defaultUpdateShell = getDefaultUpdateShell(deviceType);
|
||||
const updateStatus = useUpdateCheck();
|
||||
|
||||
const updateBadgeText = updateStatus.critical
|
||||
? "Critical Update"
|
||||
: updateStatus.announcementBadge ?? null;
|
||||
|
||||
// Warn before closing the tab only when training is running (data loss risk).
|
||||
// We store the handler in a ref so removeUnloadHandler() can clean it up
|
||||
|
|
@ -434,17 +455,24 @@ export function Navbar() {
|
|||
<HoverCardTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
className={cn(
|
||||
"flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent",
|
||||
updateBadgeText
|
||||
? "text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
aria-label="How to update Unsloth Studio"
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowReloadHorizontalIcon} className="size-4" />
|
||||
Update
|
||||
{updateBadgeText ?? "Update"}
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="end" className="w-[22.5rem] p-0">
|
||||
<UpdateStudioInstructions
|
||||
className="p-4"
|
||||
defaultShell={defaultUpdateShell}
|
||||
announcementMessage={updateStatus.announcementMessage}
|
||||
announcementUrl={updateStatus.announcementUrl}
|
||||
/>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
|
@ -555,17 +583,23 @@ export function Navbar() {
|
|||
<Collapsible
|
||||
open={mobileUpdateOpen}
|
||||
onOpenChange={setMobileUpdateOpen}
|
||||
className="rounded-md border border-border"
|
||||
className={cn(
|
||||
"rounded-md border",
|
||||
updateBadgeText ? "border-amber-400 dark:border-amber-700" : "border-border",
|
||||
)}
|
||||
>
|
||||
<CollapsibleTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium transition-colors hover:bg-accent",
|
||||
updateBadgeText ? "text-amber-600 dark:text-amber-400" : "text-foreground",
|
||||
)}
|
||||
aria-label="Toggle update instructions"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<HugeiconsIcon icon={ArrowReloadHorizontalIcon} className="size-4" />
|
||||
Update Unsloth Studio
|
||||
{updateBadgeText ?? "Update Unsloth Studio"}
|
||||
</span>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowRight01Icon}
|
||||
|
|
@ -580,6 +614,8 @@ export function Navbar() {
|
|||
<UpdateStudioInstructions
|
||||
defaultShell={defaultUpdateShell}
|
||||
showTitle={false}
|
||||
announcementMessage={updateStatus.announcementMessage}
|
||||
announcementUrl={updateStatus.announcementUrl}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
|
|
|||
|
|
@ -11,3 +11,4 @@ export { useHfDatasetSearch } from "./use-hf-dataset-search";
|
|||
export { useHfDatasetSplits } from "./use-hf-dataset-splits";
|
||||
export { useHfTokenValidation } from "./use-hf-token-validation";
|
||||
export { useInfiniteScroll } from "./use-infinite-scroll";
|
||||
export { useUpdateCheck } from "./use-update-check";
|
||||
|
|
|
|||
76
studio/frontend/src/hooks/use-update-check.ts
Normal file
76
studio/frontend/src/hooks/use-update-check.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// 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 { useEffect, useState } from "react";
|
||||
|
||||
export interface UpdateCheckInfo {
|
||||
critical: boolean;
|
||||
announcementBadge: string | null;
|
||||
announcementMessage: string | null;
|
||||
announcementUrl: string | null;
|
||||
manifestFetched: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT: UpdateCheckInfo = {
|
||||
critical: false,
|
||||
announcementBadge: null,
|
||||
announcementMessage: null,
|
||||
announcementUrl: null,
|
||||
manifestFetched: false,
|
||||
};
|
||||
|
||||
// Module-level cache so multiple components share one fetch.
|
||||
let cached: UpdateCheckInfo | null = null;
|
||||
let fetchPromise: Promise<UpdateCheckInfo> | null = null;
|
||||
|
||||
async function fetchOnce(): Promise<UpdateCheckInfo> {
|
||||
if (cached) return cached;
|
||||
if (fetchPromise) return fetchPromise;
|
||||
|
||||
fetchPromise = (async () => {
|
||||
try {
|
||||
// Unauthenticated endpoint -- use plain fetch, not authFetch.
|
||||
const res = await fetch("/api/update-check");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const info: UpdateCheckInfo = {
|
||||
critical: data?.critical ?? false,
|
||||
announcementBadge: data?.announcement_badge ?? null,
|
||||
announcementMessage: data?.announcement_message ?? null,
|
||||
announcementUrl: data?.announcement_url ?? null,
|
||||
manifestFetched: data?.manifest_fetched ?? false,
|
||||
};
|
||||
cached = info;
|
||||
return info;
|
||||
} catch {
|
||||
fetchPromise = null;
|
||||
return DEFAULT;
|
||||
}
|
||||
})();
|
||||
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch update-check info from `GET /api/update-check`.
|
||||
*
|
||||
* The result is cached at module level -- only one network request is made
|
||||
* regardless of how many components call this hook.
|
||||
*/
|
||||
export function useUpdateCheck(): UpdateCheckInfo {
|
||||
const [info, setInfo] = useState<UpdateCheckInfo>(cached ?? DEFAULT);
|
||||
|
||||
useEffect(() => {
|
||||
if (cached) return;
|
||||
|
||||
let cancelled = false;
|
||||
fetchOnce().then((status) => {
|
||||
if (!cancelled) setInfo(status);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
|
@ -523,19 +523,66 @@ def install_python_stack() -> int:
|
|||
package_name,
|
||||
)
|
||||
else:
|
||||
# Update path: upgrade only unsloth + unsloth-zoo while preserving
|
||||
# existing torch/CUDA installations. Torch is pre-installed by
|
||||
# install.sh / setup.ps1; --upgrade-package targets only base pkgs.
|
||||
_progress("base packages")
|
||||
pip_install(
|
||||
"Updating base packages",
|
||||
"--no-cache-dir",
|
||||
"--upgrade-package",
|
||||
"unsloth",
|
||||
"--upgrade-package",
|
||||
"unsloth-zoo",
|
||||
req = REQ_ROOT / "base.txt",
|
||||
)
|
||||
# Manifest override: install from git main instead of PyPI
|
||||
_git_ref = os.environ.get("STUDIO_UNSLOTH_GIT_REF", "")
|
||||
if _git_ref:
|
||||
_progress("base packages (git)")
|
||||
pip_install(
|
||||
f"Installing unsloth from git@{_git_ref}",
|
||||
"--no-cache-dir",
|
||||
"--no-deps",
|
||||
f"git+https://github.com/unslothai/unsloth.git@{_git_ref}",
|
||||
constrain = False,
|
||||
)
|
||||
# Install remaining base deps (unsloth-zoo etc.) but exclude
|
||||
# the "unsloth" line so pip does not revert the git checkout
|
||||
# back to the PyPI wheel. We match "unsloth" exactly (not
|
||||
# "unsloth-zoo") by checking for a version specifier or EOL
|
||||
# immediately after the package name.
|
||||
import re as _re
|
||||
|
||||
_base_lines = (
|
||||
(REQ_ROOT / "base.txt")
|
||||
.read_text(encoding = "utf-8")
|
||||
.splitlines(keepends = True)
|
||||
)
|
||||
_base_filtered = [
|
||||
ln
|
||||
for ln in _base_lines
|
||||
if not _re.match(r"^\s*unsloth\s*([<>=!~;\[#]|$)", ln.strip())
|
||||
]
|
||||
_base_tmp = tempfile.NamedTemporaryFile(
|
||||
mode = "w",
|
||||
suffix = ".txt",
|
||||
delete = False,
|
||||
encoding = "utf-8",
|
||||
)
|
||||
_base_tmp.writelines(_base_filtered)
|
||||
_base_tmp.close()
|
||||
try:
|
||||
pip_install(
|
||||
"Updating remaining base packages",
|
||||
"--no-cache-dir",
|
||||
"--upgrade-package",
|
||||
"unsloth-zoo",
|
||||
req = Path(_base_tmp.name),
|
||||
)
|
||||
finally:
|
||||
Path(_base_tmp.name).unlink(missing_ok = True)
|
||||
else:
|
||||
# Update path: upgrade only unsloth + unsloth-zoo while preserving
|
||||
# existing torch/CUDA installations. Torch is pre-installed by
|
||||
# install.sh / setup.ps1; --upgrade-package targets only base pkgs.
|
||||
_progress("base packages")
|
||||
pip_install(
|
||||
"Updating base packages",
|
||||
"--no-cache-dir",
|
||||
"--upgrade-package",
|
||||
"unsloth",
|
||||
"--upgrade-package",
|
||||
"unsloth-zoo",
|
||||
req = REQ_ROOT / "base.txt",
|
||||
)
|
||||
|
||||
# 3. Extra dependencies
|
||||
_progress("unsloth extras")
|
||||
|
|
|
|||
110
studio/setup.sh
110
studio/setup.sh
|
|
@ -27,6 +27,50 @@ _DEFAULT_LLAMA_SOURCE="https://github.com/ggml-org/llama.cpp"
|
|||
_DEFAULT_LLAMA_TAG="latest"
|
||||
_DEFAULT_LLAMA_FORCE_COMPILE_REF="master"
|
||||
|
||||
# ── Fetch remote manifest ────────────────────────────────────────────
|
||||
# UNSLOTH_UPDATE_DETAILS.json controls update behavior centrally.
|
||||
# Fields are parsed into MANIFEST_* shell variables; all default to
|
||||
# empty when the fetch fails (graceful degradation).
|
||||
_MANIFEST_URL="https://raw.githubusercontent.com/unslothai/unsloth/main/UNSLOTH_UPDATE_DETAILS.json"
|
||||
MANIFEST_UNSLOTH_SOURCE=""
|
||||
MANIFEST_UNSLOTH_GITHUB_REF=""
|
||||
MANIFEST_LLAMA_CPP_SOURCE=""
|
||||
MANIFEST_LLAMA_CPP_TAG=""
|
||||
MANIFEST_CRITICAL_TIME=""
|
||||
MANIFEST_ANNOUNCEMENT_MESSAGE=""
|
||||
MANIFEST_ANNOUNCEMENT_BADGE=""
|
||||
MANIFEST_ANNOUNCEMENT_URL=""
|
||||
|
||||
_fetch_manifest() {
|
||||
local _raw
|
||||
_raw=$(curl -fsSL --max-time 8 "$_MANIFEST_URL" 2>/dev/null) || return 0
|
||||
# Parse all manifest fields in a single Python invocation to avoid
|
||||
# spawning 8 separate processes (each adds ~50ms startup overhead).
|
||||
eval "$(printf '%s' "$_raw" | python -c "
|
||||
import sys, json, shlex
|
||||
try:
|
||||
m = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
def s(v, default=''):
|
||||
'''Convert to string, treating None/null as empty.'''
|
||||
return str(v) if v is not None else default
|
||||
a = m.get('announcement') or {}
|
||||
for k, v in [
|
||||
('MANIFEST_UNSLOTH_SOURCE', s(m.get('unsloth_source'))),
|
||||
('MANIFEST_UNSLOTH_GITHUB_REF', s(m.get('unsloth_github_ref'), 'main')),
|
||||
('MANIFEST_LLAMA_CPP_SOURCE', s(m.get('llama_cpp_source'))),
|
||||
('MANIFEST_LLAMA_CPP_TAG', s(m.get('llama_cpp_tag'))),
|
||||
('MANIFEST_CRITICAL_TIME', s(m.get('CRITICAL_TIME'))),
|
||||
('MANIFEST_ANNOUNCEMENT_MESSAGE', s(a.get('message'))),
|
||||
('MANIFEST_ANNOUNCEMENT_BADGE', s(a.get('badge'))),
|
||||
('MANIFEST_ANNOUNCEMENT_URL', s(a.get('url'))),
|
||||
]:
|
||||
print(f'{k}={shlex.quote(v)}')
|
||||
" 2>/dev/null)" || true
|
||||
substep "manifest: source=${MANIFEST_UNSLOTH_SOURCE:-pypi} llama=${MANIFEST_LLAMA_CPP_SOURCE:-default}@${MANIFEST_LLAMA_CPP_TAG:-default}"
|
||||
}
|
||||
|
||||
# ── Colors (same palette as startup_banner / install_python_stack) ──
|
||||
if [ -n "${NO_COLOR:-}" ]; then
|
||||
C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST=
|
||||
|
|
@ -166,6 +210,7 @@ echo ""
|
|||
printf " ${C_TITLE}%s${C_RST}\n" "🦥 Unsloth Studio Setup"
|
||||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
verbose_substep "verbose diagnostics enabled"
|
||||
_fetch_manifest
|
||||
_LLAMA_ONLY="${UNSLOTH_STUDIO_LLAMA_ONLY:-0}"
|
||||
if [ "$_LLAMA_ONLY" = "1" ]; then
|
||||
substep "llama.cpp only mode"
|
||||
|
|
@ -477,8 +522,22 @@ _SKIP_VERSION_CHECK=false
|
|||
if [ "$_COLAB_NO_VENV" = true ]; then
|
||||
_SKIP_VERSION_CHECK=true
|
||||
fi
|
||||
|
||||
# Apply manifest: install from git main instead of PyPI when directed.
|
||||
# Must be evaluated BEFORE the PyPI version check so the fast-path
|
||||
# does not suppress the git-main install.
|
||||
_MANIFEST_FORCE_GIT=false
|
||||
if [ "$MANIFEST_UNSLOTH_SOURCE" = "main" ] && [ "${STUDIO_LOCAL_INSTALL:-0}" != "1" ]; then
|
||||
export STUDIO_UNSLOTH_GIT_REF="${MANIFEST_UNSLOTH_GITHUB_REF:-main}"
|
||||
_MANIFEST_FORCE_GIT=true
|
||||
substep "manifest override: installing unsloth from git@${STUDIO_UNSLOTH_GIT_REF}"
|
||||
fi
|
||||
|
||||
_PKG_NAME="${STUDIO_PACKAGE_NAME:-unsloth}"
|
||||
if [ "$_SKIP_VERSION_CHECK" != true ] && [ "${SKIP_STUDIO_BASE:-0}" != "1" ] && [ "${STUDIO_LOCAL_INSTALL:-0}" != "1" ]; then
|
||||
if [ "$_MANIFEST_FORCE_GIT" != true ] && \
|
||||
[ "$_SKIP_VERSION_CHECK" != true ] && \
|
||||
[ "${SKIP_STUDIO_BASE:-0}" != "1" ] && \
|
||||
[ "${STUDIO_LOCAL_INSTALL:-0}" != "1" ]; then
|
||||
# Only check when NOT called from install.sh (which just installed the package)
|
||||
INSTALLED_VER=$("$VENV_DIR/bin/python" -c "
|
||||
from importlib.metadata import version
|
||||
|
|
@ -533,6 +592,23 @@ if [ "$_HOST_SYSTEM" = "Darwin" ]; then
|
|||
else
|
||||
_HELPER_RELEASE_REPO="unslothai/llama.cpp"
|
||||
fi
|
||||
|
||||
# Apply manifest overrides for llama.cpp (only if user hasn't set env vars).
|
||||
# Skip on macOS -- macOS must use ggml-org (unslothai/llama.cpp has no macOS assets).
|
||||
if [ -n "$MANIFEST_LLAMA_CPP_SOURCE" ] && [ -z "${UNSLOTH_LLAMA_PUBLISHED_REPO:-}" ] && [ "$_HOST_SYSTEM" != "Darwin" ]; then
|
||||
if [ "$MANIFEST_LLAMA_CPP_SOURCE" = "ggml-org" ]; then
|
||||
_HELPER_RELEASE_REPO="ggml-org/llama.cpp"
|
||||
elif [ "$MANIFEST_LLAMA_CPP_SOURCE" = "unslothai" ]; then
|
||||
_HELPER_RELEASE_REPO="unslothai/llama.cpp"
|
||||
fi
|
||||
verbose_substep "manifest override: llama.cpp repo=$_HELPER_RELEASE_REPO"
|
||||
fi
|
||||
if [ -n "$MANIFEST_LLAMA_CPP_TAG" ] && [ -z "${UNSLOTH_LLAMA_TAG:-}" ]; then
|
||||
_DEFAULT_LLAMA_TAG="$MANIFEST_LLAMA_CPP_TAG"
|
||||
_REQUESTED_LLAMA_TAG="$MANIFEST_LLAMA_CPP_TAG"
|
||||
verbose_substep "manifest override: llama.cpp tag=$_REQUESTED_LLAMA_TAG"
|
||||
fi
|
||||
|
||||
_LLAMA_PR="${UNSLOTH_LLAMA_PR:-}"
|
||||
_SKIP_PREBUILT_INSTALL=false
|
||||
_LLAMA_PR_FORCE="${UNSLOTH_LLAMA_PR_FORCE:-${_DEFAULT_LLAMA_PR_FORCE}}"
|
||||
|
|
@ -989,6 +1065,38 @@ else
|
|||
}
|
||||
fi # end _SKIP_GGUF_BUILD check
|
||||
|
||||
# ── Write install timestamp (only when python deps were actually updated) ──
|
||||
# Guard against uninitialized _SKIP_PYTHON_DEPS in llama-only mode.
|
||||
if [ "$_LLAMA_ONLY" != "1" ] && [ "${_SKIP_PYTHON_DEPS:-true}" = false ]; then
|
||||
_STUDIO_INFO_DIR="$HOME/.unsloth/studio"
|
||||
mkdir -p "$_STUDIO_INFO_DIR"
|
||||
python - "$_STUDIO_INFO_DIR/UNSLOTH_STUDIO_INFO.json" <<'PY' 2>/dev/null || true
|
||||
import json, sys, time, calendar
|
||||
from pathlib import Path
|
||||
|
||||
info_path = Path(sys.argv[1])
|
||||
existing = {}
|
||||
if info_path.is_file():
|
||||
try:
|
||||
existing = json.loads(info_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import importlib.metadata as _meta
|
||||
try:
|
||||
_ver = _meta.version("unsloth")
|
||||
except Exception:
|
||||
_ver = "unknown"
|
||||
|
||||
import os
|
||||
existing["installed_at_utc"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
existing["unsloth_version"] = _ver
|
||||
existing["unsloth_source"] = os.environ.get("STUDIO_UNSLOTH_GIT_REF", "pypi")
|
||||
info_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
|
||||
PY
|
||||
verbose_substep "wrote install info to $_STUDIO_INFO_DIR/UNSLOTH_STUDIO_INFO.json"
|
||||
fi
|
||||
|
||||
# ── Footer ──
|
||||
if [ "$_LLAMA_ONLY" = "1" ]; then
|
||||
echo ""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue