Compare commits

...
Sign in to create a new pull request.

13 commits

Author SHA1 Message Date
Daniel Han
ba0dfeab63 Revert "Add Gemma 4 model sampling defaults"
This reverts commit f01affa6a3.
2026-04-03 20:30:57 +00:00
Daniel Han
f01affa6a3 Add Gemma 4 model sampling defaults
Add per-model YAML configs and MODEL_NAME_MAPPING entries for all 8
Gemma 4 models (4 instruct + 4 base):
- gemma-4-31B-it / gemma-4-31B
- gemma-4-26B-A4B-it / gemma-4-26B-A4B
- gemma-4-E2B-it / gemma-4-E2B
- gemma-4-E4B-it / gemma-4-E4B

GGUF variants (only for -it models) resolve via the gemma-4 family
entry in inference_defaults.json.

Sampling defaults: temperature=1.0, top_p=0.95, top_k=64, min_p=0.0,
no repetition or presence penalty. Matches gemma-3n and gemma-3.
2026-04-03 20:30:47 +00:00
Daniel Han
9a7d080e94 Remove unused field import from update_check.py 2026-04-03 14:51:05 +00:00
Daniel Han
3ccca16aac Fix null-to-None conversion and skip macOS llama.cpp override
- Handle JSON null values correctly in manifest parsing: convert to
  empty string instead of literal "None" which could be treated as a
  valid value downstream.

- Skip llama_cpp_source manifest override on macOS since
  unslothai/llama.cpp does not publish macOS binaries. macOS must
  always use ggml-org/llama.cpp for prebuilt assets.
2026-04-03 14:50:08 +00:00
Daniel Han
6b843a8e8e Use asyncio.to_thread for blocking manifest fetch in async endpoint
The /api/update-check endpoint is async but fetch_and_cache_update_status
performs a blocking HTTP request via urllib. Wrap it in asyncio.to_thread
to avoid blocking the FastAPI event loop when the background thread has
not yet completed the initial fetch.
2026-04-03 14:33:45 +00:00
Daniel Han
7bca7bf0db Validate announcement URL scheme and fix llama-only mode guard
- Only allow https:// and http:// URLs for announcement_url to prevent
  javascript: or data: scheme injection from a compromised manifest.

- Guard the install timestamp write with both _LLAMA_ONLY and
  _SKIP_PYTHON_DEPS checks to prevent uninitialized variable errors
  when running in UNSLOTH_STUDIO_LLAMA_ONLY=1 mode (set -u).
2026-04-03 14:29:51 +00:00
pre-commit-ci[bot]
5301c5daf0 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-03 13:52:45 +00:00
Daniel Han
2bcafd1c25 Fix base.txt filter to match 'unsloth' exactly, not 'unsloth-zoo'
The previous _filter_requirements approach matched any line starting
with "unsloth", which also caught "unsloth-zoo". Replace with a regex
that matches "unsloth" followed by a version specifier or end-of-line,
preserving "unsloth-zoo" and other packages with the "unsloth-" prefix.
2026-04-03 13:52:30 +00:00
Daniel Han
a1c7b95a5a Consolidate manifest parsing and add debug logging
- Replace 8 separate Python invocations for JSON field extraction with a
  single Python call that outputs shell-safe eval assignments via
  shlex.quote. This reduces setup.sh startup overhead by ~400ms.

- Add debug-level logging to exception handlers in update_check.py
  instead of silently swallowing errors, aiding troubleshooting when
  manifest fetch or critical-time comparison fails.
2026-04-03 13:50:26 +00:00
pre-commit-ci[bot]
67b09be3f0 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-03 13:45:35 +00:00
Daniel Han
a681c778bd Fix P1 review issues: race condition, fast-path bypass, git revert, timestamp guard
1. Fix cold-start race in /api/update-check: if the background thread has
   not finished fetching the manifest when the endpoint is hit, do an
   inline fetch so the UI never permanently caches empty defaults.

2. Fix PyPI fast-path blocking manifest git-main installs: evaluate the
   manifest unsloth_source directive BEFORE the PyPI version check so
   users already on latest PyPI still switch to git main when directed.

3. Fix base.txt reverting git install: filter out "unsloth" from base.txt
   requirements when the git-main path is active, preventing pip/uv from
   replacing the git checkout with the PyPI wheel.

4. Guard install timestamp write: only write UNSLOTH_STUDIO_INFO.json
   when python deps were actually updated, preventing users from clearing
   a critical-update warning by re-running setup in llama-only or
   skip-python-deps mode.
2026-04-03 13:45:05 +00:00
pre-commit-ci[bot]
42b60063f6 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-03 13:26:27 +00:00
Daniel Han
7eda1ba5a8 Add UNSLOTH_UPDATE_DETAILS.json manifest system for Studio updates
Adds a centralized manifest file that controls Studio update behavior,
allowing the team to temporarily switch update sources (PyPI vs git main),
control llama.cpp source/version, and notify users of critical updates --
all without requiring a PyPI release.

Changes:
- New UNSLOTH_UPDATE_DETAILS.json manifest at repo root
- setup.sh fetches manifest on startup, applies directives for unsloth
  source, llama.cpp repo/tag overrides, writes install timestamp
- install_python_stack.py supports STUDIO_UNSLOTH_GIT_REF env var for
  installing unsloth from git instead of PyPI
- New backend utils/update_check.py for manifest fetch and cache
- New /api/update-check endpoint in main.py (unauthenticated)
- Background thread checks manifest at startup, prints terminal banner
- New use-update-check.ts React hook (module-level cache pattern)
- navbar.tsx Update button shows dynamic badge and announcement
- __root.tsx shows non-dismissable critical update banner
2026-04-03 13:25:54 +00:00
9 changed files with 460 additions and 19 deletions

View 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
}

View file

@ -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,

View 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

View file

@ -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

View file

@ -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>

View file

@ -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";

View 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;
}

View file

@ -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")

View file

@ -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 ""