Merge branch 'main' into pip
This commit is contained in:
commit
5dec1284eb
441 changed files with 28968 additions and 3839 deletions
2
.gitattributes
vendored
2
.gitattributes
vendored
|
|
@ -6,7 +6,7 @@
|
|||
# them when run in WSL/Linux (e.g. `set -e` -> "set: Illegal option -").
|
||||
*.sh text eol=lf
|
||||
|
||||
# Normalize Studio frontend sources to LF. Scoped to the frontend tree (rather
|
||||
# Normalize Unsloth frontend sources to LF. Scoped to the frontend tree (rather
|
||||
# than repo-wide *.ts/*.tsx/... rules) so the policy can't force LF on files
|
||||
# elsewhere. text=auto lets Git detect and leave binary assets (logos, fonts)
|
||||
# untouched while text files (.ts/.tsx/.json/.html/.svg/...) are stored as LF.
|
||||
|
|
|
|||
21
.github/scripts/agent-guides-drive.sh
vendored
21
.github/scripts/agent-guides-drive.sh
vendored
|
|
@ -36,6 +36,23 @@ AGENT="${2:?usage: agent-guides-drive.sh <mode> <agent>}"
|
|||
# Determinism (seed/temp) is applied at the server level by
|
||||
# serve-unsloth-run.sh --extra; agents inherit it through the API.
|
||||
TIMEOUT="${AGENT_INVOKE_TIMEOUT:-180}"
|
||||
# opencode is the slow outlier. Unlike the print-mode agents (claude -p, codex
|
||||
# exec) it runs a full turn AND a separate small_model call to name the session,
|
||||
# so one connection reply takes ~8 min on a CPU-served 4B -- right at the shared
|
||||
# 600s cap, so the cell flaked when a run drifted past a ~480s success. Give it
|
||||
# headroom (still well under the 40-min job budget); the fast agents keep the
|
||||
# tight cap that still catches a real headless-TTY hang.
|
||||
case "$AGENT" in
|
||||
opencode)
|
||||
# Double it, but only for a bare-integer seconds value. A GNU timeout(1)
|
||||
# duration suffix (s/m/h/d, including floats like 0.5s) is left unchanged so
|
||||
# the arithmetic never sees a non-number; timeout(1) parses it directly.
|
||||
case "$TIMEOUT" in
|
||||
*[!0-9]*) ;;
|
||||
*) TIMEOUT=$(( TIMEOUT * 2 )) ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
# Claude refuses --dangerously-skip-permissions outside a sandbox; the CI runner
|
||||
# IS the sandbox, so declare it (mirrors unslothai/scripts launcher.sh). Harmless
|
||||
|
|
@ -166,8 +183,8 @@ parse_connect() {
|
|||
echo "[$AGENT] connect --no-launch printed:"; cat_redacted "$raw"
|
||||
CONNECT_ENV="$(grep -E '^(export |unset )' "$raw" || true)"
|
||||
# The launch command is the last non-export, non-status line. start.py
|
||||
# prints "Studio <url> · model <id>" and "Updated ..." status lines first.
|
||||
CONNECT_CMD="$(grep -vE '^(export |unset |Studio |Updated |Disabled |Warning|Loading)' "$raw" \
|
||||
# prints "Unsloth <url> · model <id>" and "Updated ..." status lines first.
|
||||
CONNECT_CMD="$(grep -vE '^(export |unset |Unsloth |Updated |Disabled |Warning|Loading)' "$raw" \
|
||||
| grep -E '[^[:space:]]' | tail -1)"
|
||||
[ -n "$CONNECT_CMD" ] || guide_fail "could not parse a launch command from connect --no-launch output"
|
||||
redact "$raw"
|
||||
|
|
|
|||
2
.github/scripts/assert-llama-loads.sh
vendored
2
.github/scripts/assert-llama-loads.sh
vendored
|
|
@ -2,7 +2,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Assert Studio installed a llama.cpp that loads and runs on THIS macOS. Tests
|
||||
# Assert Unsloth installed a llama.cpp that loads and runs on THIS macOS. Tests
|
||||
# the contract that matters (binaries load and their minimum-OS is <= this host)
|
||||
# instead of the old "did install.sh fall back to a source build?" grep, since a
|
||||
# source build with a correct deployment target is a valid outcome.
|
||||
|
|
|
|||
2
.github/scripts/assert-prompt-cache.sh
vendored
2
.github/scripts/assert-prompt-cache.sh
vendored
|
|
@ -31,7 +31,7 @@
|
|||
# (llama_cpp.py:337-340). So default: ~/.unsloth/studio/logs/llama-server/.
|
||||
#
|
||||
# <P> is the INTERNAL llama-server port (self._find_free_port(),
|
||||
# llama_cpp.py:3489 / :4641) -- a RANDOM port, NOT the Studio port. So we must
|
||||
# llama_cpp.py:3489 / :4641) -- a RANDOM port, NOT the Unsloth port. So we must
|
||||
# NOT filter the log glob by STUDIO_PORT (the brief's `port-<STUDIO_PORT>`
|
||||
# glob would never match). We pick the newest llama-*.log instead.
|
||||
#
|
||||
|
|
|
|||
4
.github/scripts/hf-download-with-retry.sh
vendored
4
.github/scripts/hf-download-with-retry.sh
vendored
|
|
@ -3,7 +3,7 @@
|
|||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
#
|
||||
# Download a single file from a Hugging Face repo with a stall-retry
|
||||
# watchdog. Used by the Studio CI workflows so a hung hf-xet transfer
|
||||
# watchdog. Used by the Unsloth CI workflows so a hung hf-xet transfer
|
||||
# kills + retries instead of silently consuming the job's timeout.
|
||||
#
|
||||
# Usage: hf-download-with-retry.sh REPO FILE LOCAL_DIR
|
||||
|
|
@ -35,7 +35,7 @@ REPO="${1:?usage: hf-download-with-retry.sh REPO FILE [LOCAL_DIR]}"
|
|||
FILE="${2:?usage: hf-download-with-retry.sh REPO FILE [LOCAL_DIR]}"
|
||||
# LOCAL_DIR is optional. If empty, hf falls back to HF_HUB_CACHE
|
||||
# (~/.cache/huggingface/hub) which is the desired path for callers
|
||||
# that populate HF_HOME for a downstream Studio model load.
|
||||
# that populate HF_HOME for a downstream Unsloth model load.
|
||||
LOCAL_DIR="${3:-}"
|
||||
|
||||
# Stall threshold per attempt, in seconds. Override with
|
||||
|
|
|
|||
69
.github/scripts/run-studio-permission-browser.sh
vendored
Executable file
69
.github/scripts/run-studio-permission-browser.sh
vendored
Executable file
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
port="${1:?usage: $0 PORT BROWSER [CHANNEL]}"
|
||||
browser="${2:?usage: $0 PORT BROWSER [CHANNEL]}"
|
||||
channel="${3:-}"
|
||||
slug="$browser${channel:+-$channel}"
|
||||
artifact_dir="logs/playwright-permissions-$slug"
|
||||
server_log="logs/studio-permissions-$slug.log"
|
||||
studio_home="${UNSLOTH_STUDIO_HOME:-$HOME/.unsloth/studio}"
|
||||
set --
|
||||
if [ -n "${STUDIO_PERMISSION_FRONTEND:-}" ]; then
|
||||
set -- -f "$STUDIO_PERMISSION_FRONTEND"
|
||||
fi
|
||||
|
||||
mkdir -p "$artifact_dir"
|
||||
unsloth studio reset-password
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$port" "$@" \
|
||||
>"$server_log" 2>&1 &
|
||||
studio_pid=$!
|
||||
|
||||
cleanup() {
|
||||
kill "$studio_pid" 2>/dev/null || true
|
||||
wait "$studio_pid" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
healthy=0
|
||||
for _ in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:$port/api/health" >/dev/null; then
|
||||
healthy=1
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$studio_pid" 2>/dev/null; then
|
||||
tail -100 "$server_log" || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$healthy" -ne 1 ]; then
|
||||
tail -100 "$server_log" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
old_password=$(cat "$studio_home/auth/.bootstrap_password")
|
||||
new_password="CIPerm-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
|
||||
echo "::add-mask::$old_password"
|
||||
echo "::add-mask::$new_password"
|
||||
fi
|
||||
|
||||
export BASE_URL="http://127.0.0.1:$port"
|
||||
export STUDIO_OLD_PW="$old_password"
|
||||
export STUDIO_NEW_PW="$new_password"
|
||||
export STUDIO_UI_STRICT=1
|
||||
export STUDIO_UI_PERMISSION_ONLY=1
|
||||
export STUDIO_UI_WALL_TIMEOUT_S=240
|
||||
export STUDIO_PLAYWRIGHT_BROWSER="$browser"
|
||||
export PW_ART_DIR="$artifact_dir"
|
||||
if [ -n "$channel" ]; then
|
||||
export STUDIO_PLAYWRIGHT_CHANNEL="$channel"
|
||||
else
|
||||
unset STUDIO_PLAYWRIGHT_CHANNEL || true
|
||||
fi
|
||||
|
||||
python tests/studio/playwright_chat_ui.py
|
||||
2
.github/workflows/consolidated-tests-ci.yml
vendored
2
.github/workflows/consolidated-tests-ci.yml
vendored
|
|
@ -268,6 +268,7 @@ jobs:
|
|||
tests/saving/test_save_shell_injection.py \
|
||||
tests/saving/test_patch_saving_none_tokenizer.py \
|
||||
tests/saving/test_fix_sentencepiece_gguf_robustness.py \
|
||||
tests/saving/test_fix_sentencepiece_tokenizer_guard.py \
|
||||
tests/saving/test_compressed_export_schemes.py \
|
||||
tests/saving/test_export_api_surface.py \
|
||||
tests/saving/test_export_dispatch.py \
|
||||
|
|
@ -358,6 +359,7 @@ jobs:
|
|||
tests/saving/test_save_shell_injection.py \
|
||||
tests/saving/test_patch_saving_none_tokenizer.py \
|
||||
tests/saving/test_fix_sentencepiece_gguf_robustness.py \
|
||||
tests/saving/test_fix_sentencepiece_tokenizer_guard.py \
|
||||
tests/saving/test_compressed_export_schemes.py \
|
||||
tests/saving/test_export_api_surface.py \
|
||||
tests/saving/test_export_dispatch.py \
|
||||
|
|
|
|||
4
.github/workflows/lint-ci.yml
vendored
4
.github/workflows/lint-ci.yml
vendored
|
|
@ -13,10 +13,10 @@
|
|||
# committed YAML / JSON config.
|
||||
#
|
||||
# TypeScript and Rust are NOT duplicated here on purpose:
|
||||
# - Studio Frontend CI runs `npm run typecheck` (= `tsc --noEmit`)
|
||||
# - Unsloth Frontend CI runs `npm run typecheck` (= `tsc --noEmit`)
|
||||
# and `npm run build` (vite/swc) on every studio/frontend/**
|
||||
# change, which is a full TS AST + type check.
|
||||
# - Studio Tauri CI runs `tauri build --debug --no-bundle` on
|
||||
# - Unsloth Tauri CI runs `tauri build --debug --no-bundle` on
|
||||
# every studio/src-tauri/** or studio/frontend/** change, which
|
||||
# compiles the Rust crate (= cargo check + cargo build).
|
||||
# Each is a stricter check than a parse-only step would be, so a
|
||||
|
|
|
|||
16
.github/workflows/local-agent-guides-ci.yml
vendored
16
.github/workflows/local-agent-guides-ci.yml
vendored
|
|
@ -154,7 +154,7 @@ jobs:
|
|||
path: gguf-cache
|
||||
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
|
|
@ -256,7 +256,7 @@ jobs:
|
|||
done
|
||||
fi
|
||||
|
||||
- name: Stop Studio
|
||||
- name: Stop Unsloth
|
||||
if: always()
|
||||
run: |
|
||||
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
|
||||
|
|
@ -359,7 +359,7 @@ jobs:
|
|||
path: gguf-cache
|
||||
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
|
|
@ -448,7 +448,7 @@ jobs:
|
|||
done
|
||||
fi
|
||||
|
||||
- name: Stop Studio
|
||||
- name: Stop Unsloth
|
||||
if: always()
|
||||
run: |
|
||||
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
|
||||
|
|
@ -543,7 +543,7 @@ jobs:
|
|||
path: gguf-cache
|
||||
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
|
|
@ -620,7 +620,7 @@ jobs:
|
|||
done
|
||||
fi
|
||||
|
||||
- name: Stop Studio
|
||||
- name: Stop Unsloth
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
|
||||
|
|
@ -706,7 +706,7 @@ jobs:
|
|||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
|
|
@ -764,7 +764,7 @@ jobs:
|
|||
done
|
||||
fi
|
||||
|
||||
- name: Stop Studio
|
||||
- name: Stop Unsloth
|
||||
if: always()
|
||||
run: |
|
||||
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
|
||||
|
|
|
|||
12
.github/workflows/mlx-ci.yml
vendored
12
.github/workflows/mlx-ci.yml
vendored
|
|
@ -130,7 +130,7 @@ jobs:
|
|||
# MLX support landed after the most recent unsloth-zoo PyPI
|
||||
# release; the wheel still raises NotImplementedError on
|
||||
# Apple Silicon when device_type.get_device_type() runs
|
||||
# unguarded. Studio's own install.sh overlays unsloth-zoo
|
||||
# unguarded. Unsloth's own install.sh overlays unsloth-zoo
|
||||
# from git main for the same reason. Pulling deps lets pip
|
||||
# resolve the platform-conditional MLX-only wheels (mlx,
|
||||
# mlx-lm, mlx-vlm gated on darwin+arm64 in unsloth-zoo's
|
||||
|
|
@ -317,13 +317,13 @@ jobs:
|
|||
echo
|
||||
done
|
||||
|
||||
# Validates the macOS prebuilt path Studio's setup.sh uses (#5963): install the
|
||||
# Validates the macOS prebuilt path Unsloth's setup.sh uses (#5963): install the
|
||||
# unslothai/llama.cpp fork's latest release, download a small public GGUF, and
|
||||
# check llama-server /completion end to end. Split and placed last so the
|
||||
# untrusted binary runs only in the final smoke step, after every HF_TOKEN step,
|
||||
# leaving no token-bearing step or shared workspace for a tampered prebuilt to
|
||||
# corrupt. GH_TOKEN: releases API; HF_TOKEN (withheld on PR): probe + GGUF fetch.
|
||||
- name: Studio prebuilt llama.cpp install + GGUF download (Mac M1)
|
||||
- name: Unsloth prebuilt llama.cpp install + GGUF download (Mac M1)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -344,12 +344,12 @@ jobs:
|
|||
|
||||
# Final step: runs the downloaded binaries with no secrets present, and clears
|
||||
# the GitHub Actions command files so a tampered prebuilt cannot influence the job.
|
||||
- name: Studio prebuilt llama.cpp GGUF inference smoke (Mac M1)
|
||||
- name: Unsloth prebuilt llama.cpp GGUF inference smoke (Mac M1)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT GITHUB_STEP_SUMMARY
|
||||
INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp"
|
||||
# Studio bundles only llama-server + llama-quantize (not llama-cli);
|
||||
# Unsloth bundles only llama-server + llama-quantize (not llama-cli);
|
||||
# inference goes through llama-server's HTTP /completion endpoint.
|
||||
LLAMA_SERVER="$INSTALL_DIR/build/bin/llama-server"
|
||||
LLAMA_QUANT="$INSTALL_DIR/build/bin/llama-quantize"
|
||||
|
|
@ -400,4 +400,4 @@ jobs:
|
|||
tail -40 /tmp/llama-server.log
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: Studio prebuilt llama.cpp on Mac M1 + GGUF /completion works"
|
||||
echo "OK: Unsloth prebuilt llama.cpp on Mac M1 + GGUF /completion works"
|
||||
|
|
|
|||
385
.github/workflows/release-desktop.yml
vendored
385
.github/workflows/release-desktop.yml
vendored
|
|
@ -4,7 +4,7 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
studio_version:
|
||||
description: 'Studio version tag to release (for example, v0.1.39-beta)'
|
||||
description: 'Unsloth version tag to release (for example, v0.1.39-beta)'
|
||||
type: string
|
||||
required: true
|
||||
pypi_version:
|
||||
|
|
@ -19,6 +19,19 @@ on:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DESKTOP_RELEASE_NOTES: |
|
||||
Desktop app for Unsloth Studio.
|
||||
|
||||
**macOS**: Download the Apple Silicon `.dmg`.
|
||||
**Windows**: Download the `-setup.exe` installer.
|
||||
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
|
||||
|
||||
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
||||
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
|
||||
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
|
||||
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
|
||||
|
||||
concurrency:
|
||||
group: release-desktop-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
|
|
@ -56,7 +69,7 @@ jobs:
|
|||
if not studio_version:
|
||||
sys.exit('studio_version is required, for example v0.1.39-beta')
|
||||
if re.fullmatch(r'v?20\d{2}\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', studio_version):
|
||||
sys.exit(f'studio_version must be a Studio SemVer tag, not a date-style backend version: {studio_version}')
|
||||
sys.exit(f'studio_version must be an Unsloth SemVer tag, not a date-style backend version: {studio_version}')
|
||||
|
||||
semver_tag = re.compile(
|
||||
r'^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
|
||||
|
|
@ -133,7 +146,7 @@ jobs:
|
|||
print(f'pypi_version={pypi_version}', file=output)
|
||||
PY
|
||||
|
||||
- name: Verify PyPI package and Studio stamp
|
||||
- name: Verify PyPI package and Unsloth stamp
|
||||
shell: bash
|
||||
env:
|
||||
STUDIO_VERSION: ${{ steps.prepare.outputs.studio_version }}
|
||||
|
|
@ -198,7 +211,7 @@ jobs:
|
|||
fi
|
||||
python3 scripts/stamp_studio_release.py --verify-dist "$RUNNER_TEMP/pypi-unsloth-dist" --expected "$STUDIO_VERSION"
|
||||
else
|
||||
echo "scripts/stamp_studio_release.py not found; release-desktop requires #5308 to verify the PyPI Studio stamp." >&2
|
||||
echo "scripts/stamp_studio_release.py not found; release-desktop requires #5308 to verify the PyPI Unsloth stamp." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -295,14 +308,6 @@ jobs:
|
|||
PY
|
||||
|
||||
build:
|
||||
# TODO: split into a "build (no secrets)" + "publish (secrets)" job pair
|
||||
# with actions/upload-artifact handoff so the matrix build cannot
|
||||
# publish a Release on its own. The current matrix runs across
|
||||
# Linux/macOS/Windows in a single job, so the split needs artefact
|
||||
# collection across the OS matrix and is out of scope for this
|
||||
# hardening pass.
|
||||
permissions:
|
||||
contents: write # tauri-apps/tauri-action creates / uploads a GitHub Release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
|
|
@ -311,15 +316,21 @@ jobs:
|
|||
- platform: macos-latest
|
||||
args: '--target aarch64-apple-darwin'
|
||||
label: macOS (Apple Silicon)
|
||||
artifact: macos-aarch64
|
||||
release_arch: aarch64
|
||||
# - platform: macos-latest
|
||||
# args: '--target x86_64-apple-darwin'
|
||||
# label: macOS (Intel)
|
||||
- platform: ubuntu-22.04
|
||||
args: ''
|
||||
label: Linux (x64)
|
||||
artifact: linux-x64
|
||||
release_arch: x64
|
||||
- platform: windows-latest
|
||||
args: ''
|
||||
label: Windows (x64)
|
||||
artifact: windows-x64
|
||||
release_arch: x64
|
||||
|
||||
name: Build ${{ matrix.label }}
|
||||
needs: prepare-version
|
||||
|
|
@ -465,41 +476,18 @@ jobs:
|
|||
if (chmodIdx !== -1 && sha256Idx > chmodIdx) {
|
||||
throw new Error('Desktop Linux release must verify the linuxdeploy digest before chmod +x');
|
||||
}
|
||||
const releaseBodies = [];
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const match = lines[i].match(/^(\s*)releaseBody:\s*\|\s*$/);
|
||||
if (!match) continue;
|
||||
const baseIndent = match[1].length;
|
||||
const bodyLines = [];
|
||||
i += 1;
|
||||
for (; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') {
|
||||
bodyLines.push('');
|
||||
continue;
|
||||
}
|
||||
const indent = line.match(/^\s*/)[0].length;
|
||||
if (indent <= baseIndent) {
|
||||
i -= 1;
|
||||
break;
|
||||
}
|
||||
bodyLines.push(line.slice(baseIndent + 2));
|
||||
}
|
||||
releaseBodies.push(bodyLines.join('\n'));
|
||||
const releaseBody = process.env.DESKTOP_RELEASE_NOTES;
|
||||
if (!releaseBody) {
|
||||
throw new Error('DESKTOP_RELEASE_NOTES must not be empty');
|
||||
}
|
||||
if (releaseBodies.length === 0) {
|
||||
throw new Error('Expected at least one desktop release body');
|
||||
if (/\brpm\b|\.rpm/i.test(releaseBody)) {
|
||||
throw new Error('Desktop release body must not advertise RPM packages');
|
||||
}
|
||||
for (const body of releaseBodies) {
|
||||
if (/\brpm\b|\.rpm/i.test(body)) {
|
||||
throw new Error('Desktop release body must not advertise RPM packages');
|
||||
}
|
||||
if (/AppImage.*universal|universal.*AppImage/i.test(body)) {
|
||||
throw new Error('Desktop release body must not advertise AppImage as universal');
|
||||
}
|
||||
if (!/AppImage.*experimental/i.test(body)) {
|
||||
throw new Error('Desktop release body must mark AppImage as experimental');
|
||||
}
|
||||
if (/AppImage.*universal|universal.*AppImage/i.test(releaseBody)) {
|
||||
throw new Error('Desktop release body must not advertise AppImage as universal');
|
||||
}
|
||||
if (!/AppImage.*experimental/i.test(releaseBody)) {
|
||||
throw new Error('Desktop release body must mark AppImage as experimental');
|
||||
}
|
||||
JS
|
||||
|
||||
|
|
@ -644,48 +632,33 @@ jobs:
|
|||
dest="$tools_dir/linuxdeploy-x86_64.AppImage"
|
||||
curl -fsSL "$LINUXDEPLOY_URL" -o "$dest"
|
||||
# Verify the digest BEFORE the binary is ever marked executable. The
|
||||
# next step builds the AppImage with the Tauri signing key and a
|
||||
# contents:write GITHUB_TOKEN in scope, so a substituted linuxdeploy
|
||||
# that ran here could exfiltrate signing material or tamper with
|
||||
# published release artifacts. Fail closed on any mismatch.
|
||||
# next step builds the AppImage with the Tauri signing key, so a
|
||||
# substituted linuxdeploy that ran here could exfiltrate signing
|
||||
# material or tamper with release artifacts. Fail closed on any
|
||||
# mismatch.
|
||||
echo "${LINUXDEPLOY_SHA256} ${dest}" | sha256sum -c -
|
||||
chmod +x "$dest"
|
||||
|
||||
# ── Linux: build + sign + upload ──
|
||||
# ── Linux: build + sign ──
|
||||
- name: Build Linux app
|
||||
id: build_linux
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
XDG_CACHE_HOME: ${{ runner.temp }}/tauri-tools-cache
|
||||
with:
|
||||
projectPath: studio
|
||||
tauriScript: npx --prefix . tauri
|
||||
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
|
||||
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
|
||||
releaseBody: |
|
||||
Desktop app for Unsloth Studio.
|
||||
|
||||
**macOS**: Download the Apple Silicon `.dmg`.
|
||||
**Windows**: Download the `-setup.exe` installer.
|
||||
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
|
||||
|
||||
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
||||
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
|
||||
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
|
||||
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
|
||||
releaseDraft: ${{ inputs.draft }}
|
||||
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
|
||||
args: -v ${{ matrix.args }}
|
||||
|
||||
# ── macOS: build + sign + notarize + upload ──
|
||||
# ── macOS: build + sign + notarize ──
|
||||
- name: Build macOS app
|
||||
id: build_macos
|
||||
if: matrix.platform == 'macos-latest'
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
|
|
@ -695,29 +668,14 @@ jobs:
|
|||
with:
|
||||
projectPath: studio
|
||||
tauriScript: npx --prefix . tauri
|
||||
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
|
||||
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
|
||||
releaseBody: |
|
||||
Desktop app for Unsloth Studio.
|
||||
|
||||
**macOS**: Download the Apple Silicon `.dmg`.
|
||||
**Windows**: Download the `-setup.exe` installer.
|
||||
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
|
||||
|
||||
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
||||
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
|
||||
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
|
||||
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
|
||||
releaseDraft: ${{ inputs.draft }}
|
||||
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
|
||||
args: -v ${{ matrix.args }}
|
||||
|
||||
# ── Windows: build + sign + upload ──
|
||||
# ── Windows: build + sign ──
|
||||
- name: Build Windows app
|
||||
id: build_windows
|
||||
if: matrix.platform == 'windows-latest'
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
|
@ -728,35 +686,83 @@ jobs:
|
|||
with:
|
||||
projectPath: studio
|
||||
tauriScript: npx --prefix . tauri
|
||||
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
|
||||
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
|
||||
releaseBody: |
|
||||
Desktop app for Unsloth Studio.
|
||||
|
||||
**macOS**: Download the Apple Silicon `.dmg`.
|
||||
**Windows**: Download the `-setup.exe` installer.
|
||||
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
|
||||
|
||||
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
||||
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
|
||||
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
|
||||
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
|
||||
releaseDraft: ${{ inputs.draft }}
|
||||
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
|
||||
args: -v ${{ matrix.args }}
|
||||
|
||||
# Release process note: only non-draft workflow runs advance the public
|
||||
# desktop-latest updater channel. Draft builds are for private review; if a
|
||||
# draft is manually published later, this channel intentionally remains
|
||||
# unchanged until a narrow manual channel-publish flow is added or a public
|
||||
# desktop release is created by running this workflow with draft=false.
|
||||
publish-updater-channel:
|
||||
name: Publish desktop updater channel
|
||||
- name: Stage release assets
|
||||
shell: bash
|
||||
env:
|
||||
ARTIFACT_PATHS: ${{ steps.build_linux.outputs.artifactPaths || steps.build_macos.outputs.artifactPaths || steps.build_windows.outputs.artifactPaths }}
|
||||
RELEASE_ARCH: ${{ matrix.release_arch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON=python3
|
||||
else
|
||||
PYTHON=python
|
||||
fi
|
||||
"$PYTHON" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
raw_paths = os.environ.get('ARTIFACT_PATHS', '')
|
||||
try:
|
||||
artifact_paths = json.loads(raw_paths)
|
||||
except json.JSONDecodeError as error:
|
||||
sys.exit(f'Invalid tauri-action artifactPaths output: {error}')
|
||||
if not isinstance(artifact_paths, list) or not artifact_paths:
|
||||
sys.exit('tauri-action did not return any release artifacts')
|
||||
|
||||
destination = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
staged = []
|
||||
for raw_path in artifact_paths:
|
||||
source = pathlib.Path(raw_path)
|
||||
if not source.is_file():
|
||||
continue
|
||||
name = source.name
|
||||
for extension in ('.app.tar.gz.sig', '.app.tar.gz'):
|
||||
if name.endswith(extension):
|
||||
name = f'{name[:-len(extension)]}_{os.environ["RELEASE_ARCH"]}{extension}'
|
||||
break
|
||||
name = unicodedata.normalize('NFD', name)
|
||||
name = ''.join(character for character in name if not unicodedata.combining(character))
|
||||
name = re.sub(r'[ ()\[\]{}]', '.', name)
|
||||
while '..' in name:
|
||||
name = name.replace('..', '.')
|
||||
target = destination / name
|
||||
if target.exists():
|
||||
sys.exit(f'Duplicate staged release asset name: {name}')
|
||||
shutil.copy2(source, target)
|
||||
staged.append(name)
|
||||
|
||||
if not staged:
|
||||
sys.exit('No release files were staged')
|
||||
print('Staged release assets:')
|
||||
print('\n'.join(sorted(staged)))
|
||||
PY
|
||||
|
||||
- name: Upload signed release assets
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: desktop-release-${{ matrix.artifact }}
|
||||
path: ${{ runner.temp }}/desktop-release-assets/*
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 1
|
||||
|
||||
# Only this job gets write access; builds hand off signed files via artifacts.
|
||||
# Draft runs do not advance the public desktop-latest channel.
|
||||
publish-release:
|
||||
name: Publish desktop release
|
||||
needs: [prepare-version, build]
|
||||
if: ${{ !inputs.draft }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
contents: write # create the versioned Release and replace updater-channel metadata
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
|
||||
|
|
@ -765,7 +771,164 @@ jobs:
|
|||
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
|
||||
|
||||
steps:
|
||||
- name: Harden runner (audit)
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download signed release assets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: desktop-release-*
|
||||
path: ${{ runner.temp }}/desktop-release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Validate release asset set
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 <<'PY'
|
||||
import pathlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
asset_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
|
||||
files = [path for path in asset_dir.iterdir() if path.is_file()]
|
||||
required_suffixes = (
|
||||
'.dmg',
|
||||
'.app.tar.gz',
|
||||
'.app.tar.gz.sig',
|
||||
'.deb',
|
||||
'.AppImage',
|
||||
'.AppImage.sig',
|
||||
'-setup.exe',
|
||||
'-setup.exe.sig',
|
||||
)
|
||||
for suffix in required_suffixes:
|
||||
matches = [path for path in files if path.name.endswith(suffix)]
|
||||
if len(matches) != 1:
|
||||
sys.exit(f'Expected exactly one {suffix} release asset, found {len(matches)}')
|
||||
if any(path.name == 'latest.json' for path in files):
|
||||
sys.exit('Build artifacts must not supply latest.json')
|
||||
print('\n'.join(sorted(path.name for path in files)))
|
||||
PY
|
||||
|
||||
- name: Create or validate versioned release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_DRAFT: ${{ inputs.draft }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
notes_file="$RUNNER_TEMP/desktop-release-notes.md"
|
||||
printf '%s\n' "$DESKTOP_RELEASE_NOTES" > "$notes_file"
|
||||
|
||||
release_json="$RUNNER_TEMP/versioned-release.json"
|
||||
# REST tag lookup omits drafts; `gh release view` also checks pending tags.
|
||||
if gh release view "$DESKTOP_RELEASE_TAG" \
|
||||
--json tagName,isDraft,isPrerelease > "$release_json" 2>/dev/null; then
|
||||
python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
release = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'versioned-release.json').read_text())
|
||||
expected_draft = os.environ['RELEASE_DRAFT'].lower() == 'true'
|
||||
expected_prerelease = os.environ['DESKTOP_PRERELEASE'].lower() == 'true'
|
||||
if release.get('tagName') != os.environ['DESKTOP_RELEASE_TAG']:
|
||||
sys.exit('Existing desktop release tag does not match the requested tag')
|
||||
if bool(release.get('isDraft')) != expected_draft:
|
||||
sys.exit('Existing desktop release draft state does not match the workflow input')
|
||||
if bool(release.get('isPrerelease')) != expected_prerelease:
|
||||
sys.exit('Existing desktop release prerelease state does not match the requested version')
|
||||
PY
|
||||
else
|
||||
release_flags=(
|
||||
--title "Unsloth Studio (Desktop) ${STUDIO_VERSION}"
|
||||
--notes-file "$notes_file"
|
||||
--target "$GITHUB_SHA"
|
||||
)
|
||||
if [ "$RELEASE_DRAFT" = "true" ]; then
|
||||
release_flags+=(--draft)
|
||||
fi
|
||||
if [ "$DESKTOP_PRERELEASE" = "true" ]; then
|
||||
release_flags+=(--prerelease)
|
||||
fi
|
||||
gh release create "$DESKTOP_RELEASE_TAG" "${release_flags[@]}"
|
||||
fi
|
||||
|
||||
- name: Publish versioned release assets
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release upload "$DESKTOP_RELEASE_TAG" "$RUNNER_TEMP/desktop-release-assets"/* --clobber
|
||||
|
||||
- name: Generate and publish versioned updater metadata
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 <<'PY'
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
asset_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
|
||||
files = [path for path in asset_dir.iterdir() if path.is_file()]
|
||||
|
||||
def exactly_one(suffix: str) -> pathlib.Path:
|
||||
matches = [path for path in files if path.name.endswith(suffix)]
|
||||
if len(matches) != 1:
|
||||
sys.exit(f'Expected exactly one {suffix} updater asset, found {len(matches)}')
|
||||
return matches[0]
|
||||
|
||||
def entry(signature_suffix: str) -> dict[str, str]:
|
||||
signature_path = exactly_one(signature_suffix)
|
||||
bundle_name = signature_path.name.removesuffix('.sig')
|
||||
bundle_path = asset_dir / bundle_name
|
||||
if not bundle_path.is_file():
|
||||
sys.exit(f'Missing updater bundle for {signature_path.name}: {bundle_name}')
|
||||
encoded_tag = urllib.parse.quote(os.environ['DESKTOP_RELEASE_TAG'], safe='')
|
||||
encoded_name = urllib.parse.quote(bundle_name, safe='')
|
||||
return {
|
||||
'signature': signature_path.read_text(),
|
||||
'url': (
|
||||
f'https://github.com/{os.environ["GITHUB_REPOSITORY"]}/releases/download/'
|
||||
f'{encoded_tag}/{encoded_name}'
|
||||
),
|
||||
}
|
||||
|
||||
darwin = entry('.app.tar.gz.sig')
|
||||
linux = entry('.AppImage.sig')
|
||||
windows = entry('.exe.sig')
|
||||
notes = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-notes.md').read_text()
|
||||
metadata = {
|
||||
'version': os.environ['APP_VERSION'],
|
||||
'notes': notes,
|
||||
'pub_date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
|
||||
'platforms': {
|
||||
'darwin-aarch64': darwin,
|
||||
'darwin-aarch64-app': darwin,
|
||||
'linux-x86_64': linux,
|
||||
'linux-x86_64-appimage': linux,
|
||||
'windows-x86_64': windows,
|
||||
'windows-x86_64-nsis': windows,
|
||||
},
|
||||
}
|
||||
output = pathlib.Path(os.environ['RUNNER_TEMP'], 'latest.json')
|
||||
output.write_text(json.dumps(metadata, indent=2) + '\n')
|
||||
PY
|
||||
gh release upload "$DESKTOP_RELEASE_TAG" "$RUNNER_TEMP/latest.json" --clobber
|
||||
|
||||
- name: Download versioned updater metadata
|
||||
if: ${{ !inputs.draft }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
|
@ -790,6 +953,7 @@ jobs:
|
|||
test -s "$RUNNER_TEMP/desktop-updater/latest.json"
|
||||
|
||||
- name: Validate versioned updater metadata
|
||||
if: ${{ !inputs.draft }}
|
||||
shell: bash
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
|
|
@ -849,6 +1013,7 @@ jobs:
|
|||
PY
|
||||
|
||||
- name: Ensure desktop updater channel release
|
||||
if: ${{ !inputs.draft }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
|
@ -881,6 +1046,7 @@ jobs:
|
|||
PY
|
||||
|
||||
- name: Prevent updater channel downgrade
|
||||
if: ${{ !inputs.draft }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
|
@ -971,6 +1137,7 @@ jobs:
|
|||
PY
|
||||
|
||||
- name: Publish desktop updater channel metadata
|
||||
if: ${{ !inputs.draft }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
|
|
|||
30
.github/workflows/security-audit.yml
vendored
30
.github/workflows/security-audit.yml
vendored
|
|
@ -36,8 +36,8 @@
|
|||
# - unsloth `huggingfacenotorch` extras (the canonical install path
|
||||
# for fine-tuning users; pulls transformers / peft / accelerate /
|
||||
# trl / datasets / diffusers / sentence-transformers / etc.)
|
||||
# - all six Studio backend requirements files
|
||||
# - Studio frontend (npm) and Tauri shell (cargo)
|
||||
# - all six Unsloth backend requirements files
|
||||
# - Unsloth frontend (npm) and Tauri shell (cargo)
|
||||
# Each Python step builds a filtered dep list from pyproject.toml +
|
||||
# requirements/*.txt before auditing. We do NOT install any of these
|
||||
# -- pip-audit resolves through PyPI metadata, scan_packages.py
|
||||
|
|
@ -218,7 +218,7 @@ jobs:
|
|||
# on the runner). A comment line is left in place so the
|
||||
# skipped specs are obvious in the artifact.
|
||||
# The `huggingface` extra is `huggingfacenotorch` plus torch /
|
||||
# torchvision / triton, deliberately skipped: Studio backend
|
||||
# torchvision / triton, deliberately skipped: Unsloth backend
|
||||
# already pins a torch and the +cu* / +cpu local-version tags
|
||||
# trip up the PyPI resolver in `-r` mode.
|
||||
run: |
|
||||
|
|
@ -253,7 +253,7 @@ jobs:
|
|||
# `-r requirements.txt` resolves the requirements through pip's
|
||||
# dependency resolver against PyPI metadata and audits the
|
||||
# resolved tree without ever executing setup.py / install
|
||||
# hooks. Way faster than installing the full Studio runtime
|
||||
# hooks. Way faster than installing the full Unsloth runtime
|
||||
# and -- critically -- safer: an attacker who has compromised
|
||||
# a transitive dep cannot run code in this job.
|
||||
#
|
||||
|
|
@ -326,9 +326,9 @@ jobs:
|
|||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# npm: Studio frontend
|
||||
# npm: Unsloth frontend
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
- name: npm audit (Studio frontend)
|
||||
- name: npm audit (Unsloth frontend)
|
||||
# `npm audit` resolves the lockfile through the npmjs.com
|
||||
# advisory DB. `--audit-level=high` filters the noise floor
|
||||
# to only HIGH and CRITICAL. We do NOT pass --omit=dev: a
|
||||
|
|
@ -342,7 +342,7 @@ jobs:
|
|||
# Always also write the full JSON for grep-ability.
|
||||
npm audit --json > ../../logs-npm-audit.json || true
|
||||
{
|
||||
echo "## npm audit (Studio frontend)"
|
||||
echo "## npm audit (Unsloth frontend)"
|
||||
echo
|
||||
echo '```'
|
||||
tail -200 ../../logs-npm-audit.txt
|
||||
|
|
@ -350,9 +350,9 @@ jobs:
|
|||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# cargo: Studio Tauri shell
|
||||
# cargo: Unsloth Tauri shell
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
- name: cargo audit (Studio Tauri)
|
||||
- name: cargo audit (Unsloth Tauri)
|
||||
# `--deny warnings` would make the job fail on any advisory.
|
||||
# Keep non-blocking initially; drop continue-on-error after
|
||||
# the baseline closes.
|
||||
|
|
@ -362,7 +362,7 @@ jobs:
|
|||
set +e
|
||||
cargo audit | tee ../../logs-cargo-audit.txt
|
||||
{
|
||||
echo "## cargo audit (Studio Tauri)"
|
||||
echo "## cargo audit (Unsloth Tauri)"
|
||||
echo
|
||||
echo '```'
|
||||
tail -200 ../../logs-cargo-audit.txt
|
||||
|
|
@ -559,7 +559,7 @@ jobs:
|
|||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# CycloneDX SBOM. Lets downstream consumers audit what's
|
||||
# actually shipped in unsloth wheels and the Studio backend
|
||||
# actually shipped in unsloth wheels and the Unsloth backend
|
||||
# runtime. Generates one JSON file per requirements input plus
|
||||
# a combined SBOM keyed off pyproject.toml; uploads as a build
|
||||
# artifact (and a future step can attest it via SLSA).
|
||||
|
|
@ -740,7 +740,7 @@ jobs:
|
|||
# `--with-deps` makes the scan transitive: every package the
|
||||
# declared set resolves to gets fetched and pattern-scanned, not
|
||||
# just the top-level pins. Resolving the full transitive closure
|
||||
# of the unsloth + Studio dep tree downloads several hundred
|
||||
# of the unsloth + Unsloth dep tree downloads several hundred
|
||||
# archives, hence the longer timeout.
|
||||
#
|
||||
# Sharded across runners for wall-clock parallelism. Each shard
|
||||
|
|
@ -749,7 +749,7 @@ jobs:
|
|||
# composition tries to balance load:
|
||||
# - hf-stack: pyproject extras + no-torch-runtime
|
||||
# (~150 archives, transformers/peft/accelerate/...)
|
||||
# - studio: FastAPI/Studio backend + overrides + extras-no-deps
|
||||
# - studio: FastAPI/Unsloth backend + overrides + extras-no-deps
|
||||
# (~150 archives, smaller scientific stack)
|
||||
# - extras: the heavy openai-whisper / scikit-learn / librosa
|
||||
# stack (~250 archives, dominant cost)
|
||||
|
|
@ -964,7 +964,7 @@ jobs:
|
|||
# documented at scripts/scan_npm_packages.py top-of-file. The
|
||||
# script is stdlib-only so adding it does not increase the
|
||||
# transitive supply-chain surface.
|
||||
name: npm scan-packages (Studio frontend tarballs)
|
||||
name: npm scan-packages (Unsloth frontend tarballs)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: []
|
||||
|
|
@ -1173,7 +1173,7 @@ jobs:
|
|||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install Studio frontend deps (--ignore-scripts)
|
||||
- name: Install Unsloth frontend deps (--ignore-scripts)
|
||||
# `npm audit signatures` requires node_modules to be populated.
|
||||
# `--ignore-scripts` is mandatory: this is exactly the lever the
|
||||
# new-install-script gate below protects against, and we must
|
||||
|
|
|
|||
14
.github/workflows/studio-api-smoke.yml
vendored
14
.github/workflows/studio-api-smoke.yml
vendored
|
|
@ -1,7 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Studio API & Auth Tests -- HTTP-level integration tests for the
|
||||
# Unsloth API & Auth Tests -- HTTP-level integration tests for the
|
||||
# FastAPI surface. No Playwright, no model UI; tests/studio/test_studio_api_smoke.py
|
||||
# runs ~30 s and asserts:
|
||||
# - CORS hardening (no wildcard + credentials, no bootstrap leak)
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
# Reuses the GGUF cache key from studio-ui-smoke.yml so the model
|
||||
# download is one cache-hit on the second job.
|
||||
|
||||
name: Studio API CI
|
||||
name: Unsloth API CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
|
@ -40,7 +40,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
api-smoke:
|
||||
name: Studio API & Auth Tests
|
||||
name: Unsloth API & Auth Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 12
|
||||
env:
|
||||
|
|
@ -98,7 +98,7 @@ jobs:
|
|||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
|
|
@ -111,7 +111,7 @@ jobs:
|
|||
- name: Install pyjwt for the JWT-expiry forge test
|
||||
run: pip install 'pyjwt>=2.6'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
|
|
@ -144,7 +144,7 @@ jobs:
|
|||
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run Studio API & Auth tests
|
||||
- name: Run Unsloth API & Auth tests
|
||||
# The script is named WITHOUT a `test_` prefix so it isn't
|
||||
# auto-collected by pytest in Backend CI's `tests/` walk
|
||||
# (which doesn't set BASE_URL and would crash at import).
|
||||
|
|
@ -153,7 +153,7 @@ jobs:
|
|||
STUDIO_AUTH_DIR: /home/runner/.unsloth/studio/auth
|
||||
run: python tests/studio/studio_api_smoke.py
|
||||
|
||||
- name: Stop Studio
|
||||
- name: Stop Unsloth
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
|
|
|
|||
2
.github/workflows/studio-backend-ci.yml
vendored
2
.github/workflows/studio-backend-ci.yml
vendored
|
|
@ -64,7 +64,7 @@ jobs:
|
|||
- name: Install backend test dependencies (CPU only)
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Studio's declared backend deps:
|
||||
# Unsloth's declared backend deps:
|
||||
pip install -r studio/backend/requirements/studio.txt
|
||||
# Extras that studio.txt does not list but the import chain needs
|
||||
# (python-multipart for FastAPI form/file uploads, sqlalchemy/cryptography
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
# export is validated separately. No GPU / model / llama.cpp: the tests mock the probes and block
|
||||
# torch/unsloth, so the job installs only a CPU PyTorch plus import deps.
|
||||
|
||||
name: Studio export capability
|
||||
name: Unsloth export capability
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
|
|
|||
4
.github/workflows/studio-frontend-ci.yml
vendored
4
.github/workflows/studio-frontend-ci.yml
vendored
|
|
@ -136,7 +136,7 @@ jobs:
|
|||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Built bundle must not contain Studio's unstable_Provider call site
|
||||
- name: Built bundle must not contain Unsloth's unstable_Provider call site
|
||||
run: |
|
||||
set -e
|
||||
JS=$(ls dist/assets/index-*.js | head -1)
|
||||
|
|
@ -144,7 +144,7 @@ jobs:
|
|||
echo "main bundle: $JS"
|
||||
echo "unstable_Provider: hits=$HITS (assistant-ui internals contribute up to 3)"
|
||||
if [ "$HITS" -gt 3 ]; then
|
||||
echo "::error file=studio/frontend/src/features/chat/runtime-provider.tsx::Studio bundle still passes unstable_Provider through useRemoteThreadListRuntime; this is the 2026.5.1 chat-history regression. Pass adapters directly into useLocalRuntime instead."
|
||||
echo "::error file=studio/frontend/src/features/chat/runtime-provider.tsx::Unsloth bundle still passes unstable_Provider through useRemoteThreadListRuntime; this is the 2026.5.1 chat-history regression. Pass adapters directly into useLocalRuntime instead."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
|
|||
58
.github/workflows/studio-inference-smoke.yml
vendored
58
.github/workflows/studio-inference-smoke.yml
vendored
|
|
@ -1,7 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Three end-to-end smoke jobs that boot a freshly-installed Studio and
|
||||
# Three end-to-end smoke jobs that boot a freshly-installed Unsloth and
|
||||
# exercise the surfaces real users hit through the OpenAI / Anthropic
|
||||
# SDKs and curl. Each job picks the smallest model that exercises the
|
||||
# behaviour under test, primes HF_HOME via actions/cache, and shares
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
# All three jobs run in parallel. Total wall time is dominated by job 3
|
||||
# on a cold cache; warm cache cuts that to ~3 min.
|
||||
|
||||
name: Studio GGUF CI
|
||||
name: Unsloth GGUF CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
|
@ -112,7 +112,7 @@ jobs:
|
|||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
|
|
@ -125,7 +125,7 @@ jobs:
|
|||
- name: Install OpenAI + Anthropic Python SDKs
|
||||
run: pip install 'openai>=1.50' 'anthropic>=0.40'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
|
|
@ -142,7 +142,7 @@ jobs:
|
|||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Studio did not become healthy in 180s"
|
||||
echo "Unsloth did not become healthy in 180s"
|
||||
tail -200 logs/studio.log
|
||||
exit 1
|
||||
|
||||
|
|
@ -229,11 +229,11 @@ jobs:
|
|||
return replies
|
||||
|
||||
def run_anthropic():
|
||||
# Two SDK quirks vs. Studio:
|
||||
# Two SDK quirks vs. Unsloth:
|
||||
# 1. base_url must NOT include /v1 -- the SDK appends
|
||||
# /v1/messages itself; otherwise the request hits
|
||||
# /v1/v1/messages and 405s.
|
||||
# 2. The SDK sends `x-api-key` by default, but Studio's
|
||||
# 2. The SDK sends `x-api-key` by default, but Unsloth's
|
||||
# auth layer is HTTPBearer-only. Override via
|
||||
# default_headers so Authorization: Bearer ... is
|
||||
# sent instead.
|
||||
|
|
@ -276,7 +276,7 @@ jobs:
|
|||
print(
|
||||
f"[{label}] WARN non-determinism at temperature=0.0 across "
|
||||
f"{len(determinism_failures)} of {len(first)} turn(s); "
|
||||
f"small-quant model drift, not a Studio regression. "
|
||||
f"small-quant model drift, not an Unsloth regression. "
|
||||
f"Details: " + " | ".join(determinism_failures)
|
||||
)
|
||||
# Sanity: turn-2 reply should mention the earlier question, and
|
||||
|
|
@ -290,7 +290,7 @@ jobs:
|
|||
print(f"[{label}] {status_word} -- 4 turns, history grounded ('paris' present)")
|
||||
PY
|
||||
|
||||
- name: Stop Studio
|
||||
- name: Stop Unsloth
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
|
|
@ -323,7 +323,7 @@ jobs:
|
|||
# store xet chunks + blobs + snapshots = ~4 GiB compressed --
|
||||
# 4-5x file-size inflation, dominated by xet chunks. Use main's
|
||||
# `--local-dir gguf-cache` pattern to cache the flat .gguf only.
|
||||
# Studio's /api/inference/load accepts either a HF repo (which
|
||||
# Unsloth's /api/inference/load accepts either a HF repo (which
|
||||
# uses HF_HOME) or an absolute file path; passing the absolute
|
||||
# path keeps the test off HF_HOME entirely so the cache size
|
||||
# tracks the GGUF file 1:1. The OpenAI/Anth and JSON+images
|
||||
|
|
@ -380,7 +380,7 @@ jobs:
|
|||
path: gguf-cache
|
||||
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
|
|
@ -390,7 +390,7 @@ jobs:
|
|||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Reset auth + boot Studio (API-only, default tool policy)
|
||||
- name: Reset auth + boot Unsloth (API-only, default tool policy)
|
||||
# We deliberately use the API-only mode rather than
|
||||
# `unsloth studio run` because the latter calls
|
||||
# `set_tool_policy(...)` with a resolved bool: on loopback the
|
||||
|
|
@ -503,7 +503,7 @@ jobs:
|
|||
that the tool path executed.
|
||||
|
||||
A shared CI runner can stall the stream transport (the
|
||||
connection opening, or a mid-stream read) even when Studio
|
||||
connection opening, or a mid-stream read) even when Unsloth
|
||||
is healthy, so retry a stall once with a fresh request
|
||||
capped at 300s. A stall means the stream did NOT complete,
|
||||
so partial events are normally NOT returned (an early
|
||||
|
|
@ -575,11 +575,11 @@ jobs:
|
|||
|
||||
def _tool_invoked(events):
|
||||
"""Structural check: True iff some SSE payload is a real
|
||||
tool envelope (Studio tool_start/tool_end, Anthropic
|
||||
tool envelope (Unsloth tool_start/tool_end, Anthropic
|
||||
tool_use/tool_result, OpenAI non-empty delta.tool_calls /
|
||||
message.tool_calls / finish_reason='tool_calls' /
|
||||
role:'tool' / function_call). tool_status is NOT
|
||||
evidence: Studio emits empty tool_status events on
|
||||
evidence: Unsloth emits empty tool_status events on
|
||||
iteration boundaries even when no tool ran.
|
||||
"""
|
||||
for raw in events:
|
||||
|
|
@ -698,7 +698,7 @@ jobs:
|
|||
attempt has structural invocation evidence. WARN (not
|
||||
FAIL) if invoked but no attempt produces the expected
|
||||
literal in tool_end.result -- small-quant Qwen3.5-2B can
|
||||
emit OpenAI tool_calls deltas without Studio's GGUF
|
||||
emit OpenAI tool_calls deltas without Unsloth's GGUF
|
||||
agentic loop intercepting them, and that GGUF-vs-OpenAI
|
||||
format mismatch is out of scope for #5642.
|
||||
"""
|
||||
|
|
@ -729,6 +729,7 @@ jobs:
|
|||
content, events = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"enable_tools": True,
|
||||
"permission_mode": "full",
|
||||
"enabled_tools": enabled,
|
||||
"session_id": f"{session}-att{attempt_i}",
|
||||
"temperature": TOOL_PROBE_TEMP,
|
||||
|
|
@ -810,7 +811,7 @@ jobs:
|
|||
# because (a) the search may legitimately return no results,
|
||||
# and (b) DuckDuckGo upstream blocks GHA IP ranges often
|
||||
# enough that requiring a tool_call marker would create
|
||||
# red-herring failures from infra rather than from Studio.
|
||||
# red-herring failures from infra rather than from Unsloth.
|
||||
try:
|
||||
# Best-effort and bounded: a single 180s attempt keeps a stall
|
||||
# from eating the job's timeout-minutes (it already WARNs, so a
|
||||
|
|
@ -818,6 +819,7 @@ jobs:
|
|||
content, events = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
|
||||
"enable_tools": True,
|
||||
"permission_mode": "full",
|
||||
"enabled_tools": ["web_search"],
|
||||
"session_id": "ci-tool-calling-web",
|
||||
"temperature": 0.0,
|
||||
|
|
@ -832,7 +834,7 @@ jobs:
|
|||
print(f"[tools] WARN web_search probe failed (non-blocking): {exc}")
|
||||
|
||||
# ── 5. Thinking on / off ─────────────────────────────────────
|
||||
# Studio strips think blocks from message.content for tools-mode
|
||||
# Unsloth strips think blocks from message.content for tools-mode
|
||||
# responses, so we toggle plain chat (no enable_tools) and look
|
||||
# at the surfaced reasoning_content / message.thinking field.
|
||||
def thinking_call(enable):
|
||||
|
|
@ -846,7 +848,7 @@ jobs:
|
|||
})
|
||||
assert status == 200
|
||||
msg = data["choices"][0]["message"]
|
||||
# Studio surfaces thinking via reasoning_content (OpenAI
|
||||
# Unsloth surfaces thinking via reasoning_content (OpenAI
|
||||
# extension). Fall back to inline <think> markers for
|
||||
# robustness across template versions.
|
||||
raw = (msg.get("content") or "") + (msg.get("reasoning_content") or "")
|
||||
|
|
@ -866,7 +868,7 @@ jobs:
|
|||
print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)")
|
||||
PY
|
||||
|
||||
- name: Stop Studio
|
||||
- name: Stop Unsloth
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
|
|
@ -958,7 +960,7 @@ jobs:
|
|||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v2
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
|
|
@ -971,7 +973,7 @@ jobs:
|
|||
- name: Install OpenAI + Anthropic Python SDKs
|
||||
run: pip install 'openai>=1.50' 'anthropic>=0.40'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
# See Job 2's comment: API-only mode keeps tool_policy=None so
|
||||
# response_format requests aren't routed through the agentic
|
||||
# tool loop.
|
||||
|
|
@ -1074,13 +1076,13 @@ jobs:
|
|||
# llama.cpp's HTTP server supports OpenAI-compatible JSON
|
||||
# mode: `response_format: {"type": "json_object"}` constrains
|
||||
# the model to emit syntactically-valid JSON. We use raw HTTP
|
||||
# rather than the OpenAI SDK so that the field shape Studio
|
||||
# rather than the OpenAI SDK so that the field shape Unsloth
|
||||
# forwards to llama-server is unambiguous (the SDK rewrites
|
||||
# response_format depending on which variant it recognises).
|
||||
# We deliberately do NOT pass a strict JSON schema -- on
|
||||
# small Gemma-4 quants the GBNF-from-schema path occasionally
|
||||
# produces empty output, and JSON mode is the surface we care
|
||||
# about exposing through Studio.
|
||||
# about exposing through Unsloth.
|
||||
status, data = post("/v1/chat/completions", {
|
||||
"model": "default",
|
||||
"messages": [
|
||||
|
|
@ -1110,7 +1112,7 @@ jobs:
|
|||
print(f"[json] PASS json_object -> {parsed}")
|
||||
|
||||
# ── 2. OpenAI image_url (data URI base64) ───────────────────
|
||||
# 64x64 solid-red PNG. stb_image (used by Studio's image
|
||||
# 64x64 solid-red PNG. stb_image (used by Unsloth's image
|
||||
# normaliser at routes/inference.py:3410) rejects 4x4 or
|
||||
# smaller PNGs as truncated, so we go up to 64x64 -- still
|
||||
# tiny in token cost. The assertion is loose: any non-empty
|
||||
|
|
@ -1146,9 +1148,9 @@ jobs:
|
|||
print("[image/openai] PASS image_url accepted, non-empty response")
|
||||
|
||||
# ── 3. Anthropic source/base64 image ────────────────────────
|
||||
# Two SDK quirks vs. Studio: base_url must NOT include /v1
|
||||
# Two SDK quirks vs. Unsloth: base_url must NOT include /v1
|
||||
# (the SDK appends it itself; otherwise /v1/v1/messages -> 405),
|
||||
# and Studio's auth is HTTPBearer-only so the SDK's default
|
||||
# and Unsloth's auth is HTTPBearer-only so the SDK's default
|
||||
# x-api-key header is ignored -- send Authorization: Bearer
|
||||
# via default_headers.
|
||||
anthropic = Anthropic(
|
||||
|
|
@ -1182,7 +1184,7 @@ jobs:
|
|||
print("[image/anthropic] PASS source/base64 accepted, non-empty response")
|
||||
PY
|
||||
|
||||
- name: Stop Studio
|
||||
- name: Stop Unsloth
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Event-loop regression test for the Studio model-load orchestrator.
|
||||
# Event-loop regression test for the Unsloth model-load orchestrator.
|
||||
# Pins down issue #5642 (Win10 UI freeze on model load): the /load
|
||||
# route calls LlamaCppBackend.detect_audio_type synchronously, blocking
|
||||
# the FastAPI event loop on a chain of sync httpx.Client.post() probes.
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
# danielhanchen/unsloth-staging-2 (Ubuntu / macOS / Windows all
|
||||
# green at PR time).
|
||||
|
||||
name: Studio load-orchestrator CI
|
||||
name: Unsloth load-orchestrator CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
|
|
|||
10
.github/workflows/studio-mac-api-smoke.yml
vendored
10
.github/workflows/studio-mac-api-smoke.yml
vendored
|
|
@ -33,7 +33,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
api-smoke:
|
||||
name: Studio API & Auth Tests
|
||||
name: Unsloth API & Auth Tests
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
|
|
@ -83,7 +83,7 @@ jobs:
|
|||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
|
|
@ -99,7 +99,7 @@ jobs:
|
|||
- name: Install pyjwt for the JWT-expiry forge test
|
||||
run: pip install 'pyjwt>=2.6'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
|
|
@ -129,13 +129,13 @@ jobs:
|
|||
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run Studio API & Auth tests
|
||||
- name: Run Unsloth API & Auth tests
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18895
|
||||
STUDIO_AUTH_DIR: /Users/runner/.unsloth/studio/auth
|
||||
run: python tests/studio/studio_api_smoke.py
|
||||
|
||||
- name: Stop Studio
|
||||
- name: Stop Unsloth
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
|
|
|
|||
60
.github/workflows/studio-mac-inference-smoke.yml
vendored
60
.github/workflows/studio-mac-inference-smoke.yml
vendored
|
|
@ -1,7 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Three end-to-end smoke jobs that boot a freshly-installed Studio and
|
||||
# Three end-to-end smoke jobs that boot a freshly-installed Unsloth and
|
||||
# exercise the surfaces real users hit through the OpenAI / Anthropic
|
||||
# SDKs and curl. Each job picks the smallest model that exercises the
|
||||
# behaviour under test, primes a model cache via actions/cache, and
|
||||
|
|
@ -108,7 +108,7 @@ jobs:
|
|||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
|
|
@ -124,7 +124,7 @@ jobs:
|
|||
- name: Install OpenAI + Anthropic Python SDKs
|
||||
run: pip install 'openai>=1.50' 'anthropic>=0.40'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
|
|
@ -141,7 +141,7 @@ jobs:
|
|||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Studio did not become healthy in 180s"
|
||||
echo "Unsloth did not become healthy in 180s"
|
||||
tail -200 logs/studio.log
|
||||
exit 1
|
||||
|
||||
|
|
@ -228,11 +228,11 @@ jobs:
|
|||
return replies
|
||||
|
||||
def run_anthropic():
|
||||
# Two SDK quirks vs. Studio:
|
||||
# Two SDK quirks vs. Unsloth:
|
||||
# 1. base_url must NOT include /v1 -- the SDK appends
|
||||
# /v1/messages itself; otherwise the request hits
|
||||
# /v1/v1/messages and 405s.
|
||||
# 2. The SDK sends `x-api-key` by default, but Studio's
|
||||
# 2. The SDK sends `x-api-key` by default, but Unsloth's
|
||||
# auth layer is HTTPBearer-only. Override via
|
||||
# default_headers so Authorization: Bearer ... is
|
||||
# sent instead.
|
||||
|
|
@ -283,7 +283,7 @@ jobs:
|
|||
print(f"[{label}] OK -- 4 turns, run1 == run2, history grounded")
|
||||
PY
|
||||
|
||||
- name: Stop Studio
|
||||
- name: Stop Unsloth
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
|
|
@ -363,7 +363,7 @@ jobs:
|
|||
path: gguf-cache
|
||||
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
|
|
@ -376,7 +376,7 @@ jobs:
|
|||
- name: Assert llama.cpp loads on this macOS
|
||||
run: bash .github/scripts/assert-llama-loads.sh
|
||||
|
||||
- name: Reset auth + boot Studio (API-only, default tool policy)
|
||||
- name: Reset auth + boot Unsloth (API-only, default tool policy)
|
||||
# We deliberately use the API-only mode rather than
|
||||
# `unsloth studio run` because the latter calls
|
||||
# `set_tool_policy(...)` with a resolved bool: on loopback the
|
||||
|
|
@ -478,7 +478,7 @@ jobs:
|
|||
call with enable_tools=true must use this helper.
|
||||
|
||||
A shared CI runner can stall the stream transport (the
|
||||
connection opening, or a mid-stream read) even when Studio
|
||||
connection opening, or a mid-stream read) even when Unsloth
|
||||
is healthy, so harden the read three ways: retry a stall
|
||||
once with a fresh request capped at 300s; return any text
|
||||
already streamed before a stall (a stall on the trailing
|
||||
|
|
@ -574,11 +574,11 @@ jobs:
|
|||
assert status == 200, f"tool call status {status}: {data}"
|
||||
choice = data["choices"][0]
|
||||
tool_calls = (choice.get("message") or {}).get("tool_calls") or []
|
||||
# Studio's contract: when tool_choice='required', llama.cpp's
|
||||
# Unsloth's contract: when tool_choice='required', llama.cpp's
|
||||
# grammar should force a tool_calls payload. On Mac that
|
||||
# contract is sometimes broken by the underlying quant; the
|
||||
# PASS path is "tool_calls present + correct schema", the
|
||||
# WARN path documents Studio still returned 200 with a
|
||||
# WARN path documents Unsloth still returned 200 with a
|
||||
# well-formed choices[] envelope.
|
||||
if tool_calls:
|
||||
tc = tool_calls[0]
|
||||
|
|
@ -612,6 +612,7 @@ jobs:
|
|||
content = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}],
|
||||
"enable_tools": True,
|
||||
"permission_mode": "full",
|
||||
"enabled_tools": ["python"],
|
||||
"session_id": "ci-tool-calling-py",
|
||||
"temperature": TEMP,
|
||||
|
|
@ -647,6 +648,7 @@ jobs:
|
|||
content = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
|
||||
"enable_tools": True,
|
||||
"permission_mode": "full",
|
||||
"enabled_tools": ["web_search"],
|
||||
"session_id": "ci-tool-calling-web",
|
||||
"temperature": TEMP,
|
||||
|
|
@ -658,7 +660,7 @@ jobs:
|
|||
print(f"[tools] WARN web_search probe failed (non-blocking): {exc}")
|
||||
|
||||
# ── 4. Thinking on / off ─────────────────────────────────────
|
||||
# Studio strips think blocks from message.content for tools-mode
|
||||
# Unsloth strips think blocks from message.content for tools-mode
|
||||
# responses, so we toggle plain chat (no enable_tools) and look
|
||||
# at the surfaced reasoning_content / message.thinking field.
|
||||
def thinking_call(enable):
|
||||
|
|
@ -676,7 +678,7 @@ jobs:
|
|||
}, timeout = 180)
|
||||
assert status == 200
|
||||
msg = data["choices"][0]["message"]
|
||||
# Studio surfaces thinking via reasoning_content (OpenAI
|
||||
# Unsloth surfaces thinking via reasoning_content (OpenAI
|
||||
# extension). Fall back to inline <think> markers for
|
||||
# robustness across template versions.
|
||||
raw = (msg.get("content") or "") + (msg.get("reasoning_content") or "")
|
||||
|
|
@ -702,7 +704,7 @@ jobs:
|
|||
print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)")
|
||||
PY
|
||||
|
||||
- name: Stop Studio
|
||||
- name: Stop Unsloth
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
|
|
@ -808,7 +810,7 @@ jobs:
|
|||
path: gguf-cache
|
||||
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-${{ env.MMPROJ_FILE }}-v2
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
|
|
@ -824,7 +826,7 @@ jobs:
|
|||
- name: Install OpenAI + Anthropic Python SDKs
|
||||
run: pip install 'openai>=1.50' 'anthropic>=0.40'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
# See Job 2's comment: API-only mode keeps tool_policy=None so
|
||||
# response_format requests aren't routed through the agentic
|
||||
# tool loop.
|
||||
|
|
@ -927,13 +929,13 @@ jobs:
|
|||
# llama.cpp's HTTP server supports OpenAI-compatible JSON
|
||||
# mode: `response_format: {"type": "json_object"}` constrains
|
||||
# the model to emit syntactically-valid JSON. We use raw HTTP
|
||||
# rather than the OpenAI SDK so that the field shape Studio
|
||||
# rather than the OpenAI SDK so that the field shape Unsloth
|
||||
# forwards to llama-server is unambiguous (the SDK rewrites
|
||||
# response_format depending on which variant it recognises).
|
||||
# We deliberately do NOT pass a strict JSON schema -- on
|
||||
# small Gemma-4 quants the GBNF-from-schema path occasionally
|
||||
# produces empty output, and JSON mode is the surface we care
|
||||
# about exposing through Studio.
|
||||
# about exposing through Unsloth.
|
||||
status, data = post("/v1/chat/completions", {
|
||||
"model": "default",
|
||||
"messages": [
|
||||
|
|
@ -1005,7 +1007,7 @@ jobs:
|
|||
)
|
||||
|
||||
# ── 2. OpenAI image_url (data URI base64) ───────────────────
|
||||
# 64x64 solid-red PNG. stb_image (used by Studio's image
|
||||
# 64x64 solid-red PNG. stb_image (used by Unsloth's image
|
||||
# normaliser at routes/inference.py:3410) rejects 4x4 or
|
||||
# smaller PNGs as truncated, so we go up to 64x64 -- still
|
||||
# tiny in token cost. The assertion is loose: any non-empty
|
||||
|
|
@ -1021,11 +1023,11 @@ jobs:
|
|||
# The Mac prebuilt llama.cpp server has a known crash when
|
||||
# processing image inputs alongside the gemma-4-E2B mmproj
|
||||
# (server disconnects mid-completion). This is upstream
|
||||
# llama.cpp behaviour, not Studio. Wrap both SDK calls in
|
||||
# llama.cpp behaviour, not Unsloth. Wrap both SDK calls in
|
||||
# try/except so an upstream crash registers as a WARN rather
|
||||
# than failing the whole job. Studio's contract (OpenAI/
|
||||
# than failing the whole job. Unsloth's contract (OpenAI/
|
||||
# Anthropic image fields are accepted and forwarded) is
|
||||
# validated by the request body Studio constructs, not by
|
||||
# validated by the request body Unsloth constructs, not by
|
||||
# whether llama.cpp can decode it on Mac Metal.
|
||||
client = OpenAI(base_url = f"{BASE}/v1", api_key = KEY)
|
||||
try:
|
||||
|
|
@ -1051,14 +1053,14 @@ jobs:
|
|||
except Exception as exc:
|
||||
print(
|
||||
f"[image/openai] WARN image_url SDK call raised: {type(exc).__name__}: "
|
||||
f"{exc}. Likely upstream llama.cpp Mac+vision crash, NOT a Studio "
|
||||
f"regression. Studio successfully forwarded the request."
|
||||
f"{exc}. Likely upstream llama.cpp Mac+vision crash, NOT an Unsloth "
|
||||
f"regression. Unsloth successfully forwarded the request."
|
||||
)
|
||||
|
||||
# ── 3. Anthropic source/base64 image ────────────────────────
|
||||
# Two SDK quirks vs. Studio: base_url must NOT include /v1
|
||||
# Two SDK quirks vs. Unsloth: base_url must NOT include /v1
|
||||
# (the SDK appends it itself; otherwise /v1/v1/messages -> 405),
|
||||
# and Studio's auth is HTTPBearer-only so the SDK's default
|
||||
# and Unsloth's auth is HTTPBearer-only so the SDK's default
|
||||
# x-api-key header is ignored -- send Authorization: Bearer
|
||||
# via default_headers.
|
||||
anthropic = Anthropic(
|
||||
|
|
@ -1097,11 +1099,11 @@ jobs:
|
|||
print(
|
||||
f"[image/anthropic] WARN anthropic image SDK call raised: "
|
||||
f"{type(exc).__name__}: {exc}. Likely upstream llama.cpp Mac+vision "
|
||||
f"crash, NOT a Studio regression."
|
||||
f"crash, NOT an Unsloth regression."
|
||||
)
|
||||
PY
|
||||
|
||||
- name: Stop Studio
|
||||
- name: Stop Unsloth
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Proves Studio's llama.cpp install loads on every supported macOS. The heavy
|
||||
# Proves Unsloth's llama.cpp install loads on every supported macOS. The heavy
|
||||
# app smokes stay single-OS; this matrix covers the OS-version dimension cheaply
|
||||
# (install.sh + binary-load assert). Regression guard for the macOS-version
|
||||
# selection in studio/install_llama_prebuilt.py.
|
||||
|
|
@ -60,7 +60,7 @@ jobs:
|
|||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
|
|
|
|||
29
.github/workflows/studio-mac-ui-smoke.yml
vendored
29
.github/workflows/studio-mac-ui-smoke.yml
vendored
|
|
@ -19,6 +19,7 @@ on:
|
|||
- 'install.sh'
|
||||
- 'pyproject.toml'
|
||||
- 'tests/studio/**'
|
||||
- '.github/scripts/run-studio-permission-browser.sh'
|
||||
- '.github/workflows/studio-mac-ui-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
|
|
@ -83,7 +84,7 @@ jobs:
|
|||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
|
|
@ -96,7 +97,7 @@ jobs:
|
|||
- name: Assert llama.cpp loads on this macOS
|
||||
run: bash .github/scripts/assert-llama-loads.sh
|
||||
|
||||
- name: Install Playwright + Chromium
|
||||
- name: Install Playwright browsers
|
||||
# No --with-deps on Mac: that flag installs Linux apt packages.
|
||||
# GitHub-hosted macos-14 ships the system frameworks Chromium
|
||||
# needs already.
|
||||
|
|
@ -112,7 +113,7 @@ jobs:
|
|||
# in-script retry recover from any residual flakes.
|
||||
run: |
|
||||
pip install 'playwright>=1.55,<1.58'
|
||||
python -m playwright install chromium
|
||||
python -m playwright install chromium webkit
|
||||
|
||||
- name: Patch Playwright pipeTransport.js to tolerate malformed JSON
|
||||
# In Playwright 1.55-1.58, pipeTransport.js does
|
||||
|
|
@ -143,7 +144,7 @@ jobs:
|
|||
print(f"pipeTransport.js: patched JSON.parse calls in {path}")
|
||||
PY
|
||||
|
||||
- name: Reset auth + boot Studio
|
||||
- name: Reset auth + boot Unsloth
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
|
|
@ -188,7 +189,7 @@ jobs:
|
|||
# dies mid-test, (2) Chromium net::ERR_NO_BUFFER_SPACE when the
|
||||
# runner's kernel briefly runs out of socket buffers, and (3) a
|
||||
# goto 'interrupted by another navigation' when the SPA auth
|
||||
# guard redirects mid-navigation. The retry FULLY resets Studio
|
||||
# guard redirects mid-navigation. The retry FULLY resets Unsloth
|
||||
# (kill, reset-password, reboot, wait /api/health, re-export
|
||||
# bootstrap pw) before re-running the script. A real test failure
|
||||
# (assertion / timeout) does NOT match any pattern so it bypasses
|
||||
|
|
@ -209,7 +210,7 @@ jobs:
|
|||
|| grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_attempt_${attempt}.log \
|
||||
|| grep -q "interrupted by another navigation" logs/playwright_attempt_${attempt}.log; } \
|
||||
&& [ "$attempt" -lt "$max_attempts" ]; then
|
||||
echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..."
|
||||
echo "::warning::Playwright flake on attempt ${attempt}; resetting Unsloth and retrying..."
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
unsloth studio reset-password
|
||||
|
|
@ -238,13 +239,17 @@ jobs:
|
|||
exit "$rc"
|
||||
done
|
||||
|
||||
- name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders)
|
||||
- name: Stop Unsloth (chat-ui ends with Shutdown click; this is belt-and-suspenders)
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Reset auth + boot Studio for extra UI tests (port 18897)
|
||||
- name: Cross-browser permission controls
|
||||
run: |
|
||||
bash .github/scripts/run-studio-permission-browser.sh 18895 webkit
|
||||
|
||||
- name: Reset auth + boot Unsloth for extra UI tests (port 18897)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
|
|
@ -271,7 +276,7 @@ jobs:
|
|||
echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Drive Compare/Recipes/Export/Studio/Settings with Playwright
|
||||
- name: Drive Compare/Recipes/Export/Unsloth/Settings with Playwright
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18897
|
||||
STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }}
|
||||
|
|
@ -300,7 +305,7 @@ jobs:
|
|||
|| grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_extra_attempt_${attempt}.log \
|
||||
|| grep -q "interrupted by another navigation" logs/playwright_extra_attempt_${attempt}.log; } \
|
||||
&& [ "$attempt" -lt "$max_attempts" ]; then
|
||||
echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..."
|
||||
echo "::warning::Playwright flake on attempt ${attempt}; resetting Unsloth and retrying..."
|
||||
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
unsloth studio reset-password
|
||||
|
|
@ -327,7 +332,7 @@ jobs:
|
|||
exit "$rc"
|
||||
done
|
||||
|
||||
- name: Stop second Studio
|
||||
- name: Stop second Unsloth
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
|
||||
|
|
@ -343,5 +348,7 @@ jobs:
|
|||
logs/studio_extra.log
|
||||
logs/install.log
|
||||
logs/playwright
|
||||
logs/playwright-permissions-*
|
||||
logs/playwright_extra
|
||||
logs/studio-permissions-*.log
|
||||
retention-days: 7
|
||||
|
|
|
|||
16
.github/workflows/studio-mac-update-smoke.yml
vendored
16
.github/workflows/studio-mac-update-smoke.yml
vendored
|
|
@ -4,15 +4,15 @@
|
|||
# Mac counterpart to studio-update-smoke.yml. Verifies that on a real
|
||||
# Apple Silicon (macos-14, M1) runner:
|
||||
#
|
||||
# 1. install.sh --local --no-torch installs Studio AND auto-fetches
|
||||
# 1. install.sh --local --no-torch installs Unsloth AND auto-fetches
|
||||
# the prebuilt llama.cpp Mac binary (llama-bNNNN-bin-macos-arm64
|
||||
# from ggml-org/llama.cpp). Hitting the source-build fallback is
|
||||
# treated as an Unsloth bug -- Studio must always pick the
|
||||
# treated as an Unsloth bug -- Unsloth must always pick the
|
||||
# prebuilt on Mac.
|
||||
# 2. unsloth studio update --local is idempotent. Two consecutive
|
||||
# runs both report "prebuilt up to date and validated", no
|
||||
# source-build fallback.
|
||||
# 3. The installed Studio still boots and /api/health returns
|
||||
# 3. The installed Unsloth still boots and /api/health returns
|
||||
# healthy after the update path.
|
||||
|
||||
name: Mac Studio Update CI
|
||||
|
|
@ -42,7 +42,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
update-idempotency:
|
||||
name: Studio Updating Tests
|
||||
name: Unsloth Updating Tests
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
|
|
@ -59,7 +59,7 @@ jobs:
|
|||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
|
|
@ -106,7 +106,7 @@ jobs:
|
|||
grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log
|
||||
echo "second update was clean"
|
||||
|
||||
- name: Boot Studio briefly to confirm the install is still usable
|
||||
- name: Boot Unsloth briefly to confirm the install is still usable
|
||||
run: |
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18891 \
|
||||
|
|
@ -123,13 +123,13 @@ jobs:
|
|||
sleep 1
|
||||
done
|
||||
if [ -z "$HEALTHY" ]; then
|
||||
echo "Studio failed to come up after \`update\`"
|
||||
echo "Unsloth failed to come up after \`update\`"
|
||||
tail -200 logs/studio.log
|
||||
kill "$PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
kill "$PID" 2>/dev/null || true
|
||||
echo "post-update Studio /api/health OK"
|
||||
echo "post-update Unsloth /api/health OK"
|
||||
|
||||
- name: Uninstall and verify clean
|
||||
# Round-trip through scripts/uninstall.sh on real macOS. As a side
|
||||
|
|
|
|||
2
.github/workflows/studio-tauri-smoke.yml
vendored
2
.github/workflows/studio-tauri-smoke.yml
vendored
|
|
@ -12,7 +12,7 @@
|
|||
# stay in release-desktop.yml (manual `workflow_dispatch`) because they need
|
||||
# code-signing secrets and ~30 min of runner time each.
|
||||
|
||||
name: Studio Tauri CI
|
||||
name: Unsloth Tauri CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
|
|
|||
50
.github/workflows/studio-ui-smoke.yml
vendored
50
.github/workflows/studio-ui-smoke.yml
vendored
|
|
@ -1,8 +1,8 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# End-to-end Studio chat UI smoke via Playwright + Chromium against a
|
||||
# headless Linux runner. Boots Studio with the smallest GGUF
|
||||
# End-to-end Unsloth chat UI smoke via Playwright + Chromium against a
|
||||
# headless Linux runner. Boots Unsloth with the smallest GGUF
|
||||
# (gemma-3-270m-it UD-Q4_K_XL, ~254 MiB), drives the actual frontend
|
||||
# bundle, and asserts the full bootstrap-password / change-password /
|
||||
# send-message / persist-on-reload journey works end to end.
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
# frontend-only CI happily pass while the actual user-visible UI is
|
||||
# broken (cf. the 2026.5.1 chat-history release).
|
||||
|
||||
name: Studio UI CI
|
||||
name: Unsloth UI CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
|
@ -27,6 +27,7 @@ on:
|
|||
# The Playwright test files themselves -- a PR that ONLY edits
|
||||
# the test must still trigger UI CI.
|
||||
- 'tests/studio/**'
|
||||
- '.github/scripts/run-studio-permission-browser.sh'
|
||||
- '.github/workflows/studio-ui-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
|
|
@ -97,7 +98,7 @@ jobs:
|
|||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
|
|
@ -107,15 +108,12 @@ jobs:
|
|||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Install Playwright + Chromium
|
||||
- name: Install Playwright browsers
|
||||
run: |
|
||||
pip install 'playwright>=1.45'
|
||||
# --with-deps installs the OS-level runtime libs Chromium
|
||||
# needs (libnss3, libxkbcommon, etc.). About 30 s on a
|
||||
# warm runner.
|
||||
python -m playwright install --with-deps chromium
|
||||
python -m playwright install --with-deps chromium firefox webkit
|
||||
|
||||
- name: Reset auth + boot Studio
|
||||
- name: Reset auth + boot Unsloth
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
|
|
@ -147,7 +145,7 @@ jobs:
|
|||
# NEW + NEW2 are generated freshly per CI run via secrets.token_urlsafe
|
||||
# rather than hardcoded. If a workflow gets compromised, the
|
||||
# attacker can't replay a known-good rotated password against
|
||||
# any future / parallel Studio install -- the rotated value
|
||||
# any future / parallel Unsloth install -- the rotated value
|
||||
# only ever exists for the lifetime of this single job, masked
|
||||
# in the log via ::add-mask::.
|
||||
run: |
|
||||
|
|
@ -165,29 +163,35 @@ jobs:
|
|||
env:
|
||||
BASE_URL: http://127.0.0.1:18892
|
||||
# The test file lives in the repo so it can be run locally
|
||||
# against a freshly-installed Studio (BASE_URL=...; STUDIO_OLD_PW=
|
||||
# against a freshly-installed Unsloth (BASE_URL=...; STUDIO_OLD_PW=
|
||||
# $(cat ~/.unsloth/studio/auth/.bootstrap_password); python ...).
|
||||
PW_ART_DIR: logs/playwright
|
||||
# Strict mode: in CI a missing button / nav / dialog must
|
||||
# FAIL the test. Locally the test still runs against partial
|
||||
# Studio installs without STUDIO_UI_STRICT.
|
||||
# Unsloth installs without STUDIO_UI_STRICT.
|
||||
STUDIO_UI_STRICT: '1'
|
||||
run: |
|
||||
mkdir -p logs/playwright
|
||||
python tests/studio/playwright_chat_ui.py
|
||||
|
||||
- name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders)
|
||||
- name: Stop Unsloth (chat-ui ends with Shutdown click; this is belt-and-suspenders)
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Cross-browser permission controls
|
||||
run: |
|
||||
bash .github/scripts/run-studio-permission-browser.sh 18893 firefox
|
||||
bash .github/scripts/run-studio-permission-browser.sh 18893 webkit
|
||||
bash .github/scripts/run-studio-permission-browser.sh 18893 chromium chrome
|
||||
|
||||
# The chat UI test ends by clicking the Shutdown menuitem, which
|
||||
# leaves the server dead. The extra UI test (Compare / Recipes /
|
||||
# Export / Studio / Settings) needs a fresh Studio, so we boot a
|
||||
# Export / Unsloth / Settings) needs a fresh Unsloth, so we boot a
|
||||
# second one on a different port. Boot is fast (~3-5s on the
|
||||
# warm install we already did) so this adds little wall time.
|
||||
- name: Reset auth + boot Studio for extra UI tests (port 18894)
|
||||
- name: Reset auth + boot Unsloth for extra UI tests (port 18894)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
|
|
@ -214,7 +218,7 @@ jobs:
|
|||
echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Drive Compare/Recipes/Export/Studio/Settings with Playwright
|
||||
- name: Drive Compare/Recipes/Export/Unsloth/Settings with Playwright
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18894
|
||||
STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }}
|
||||
|
|
@ -227,16 +231,16 @@ jobs:
|
|||
mkdir -p logs/playwright_extra
|
||||
python tests/studio/playwright_extra_ui.py
|
||||
|
||||
- name: Stop second Studio
|
||||
- name: Stop second Unsloth
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
# IME + multilingual paste regression (issue #5318 / PR #5327).
|
||||
# Third Studio on its own port so a hang here cannot poison the
|
||||
# Third Unsloth on its own port so a hang here cannot poison the
|
||||
# earlier UI tests. No GGUF -- the bug surface is the composer.
|
||||
- name: Reset auth + boot Studio for IME / i18n tests (port 18896)
|
||||
- name: Reset auth + boot Unsloth for IME / i18n tests (port 18896)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
|
|
@ -256,7 +260,7 @@ jobs:
|
|||
|
||||
- name: Pass bootstrap pw for IME / i18n test
|
||||
# IME smoke does the change-password against the bootstrap that
|
||||
# Studio's frontend injects into the page, so it only needs the
|
||||
# Unsloth's frontend injects into the page, so it only needs the
|
||||
# NEW password.
|
||||
run: |
|
||||
NEW="CIIme-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
|
|
@ -273,7 +277,7 @@ jobs:
|
|||
mkdir -p logs/playwright_ime
|
||||
python tests/studio/playwright_chat_ime_i18n.py
|
||||
|
||||
- name: Stop third Studio
|
||||
- name: Stop third Unsloth
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_IME_PID}" 2>/dev/null || true
|
||||
|
|
@ -297,6 +301,8 @@ jobs:
|
|||
logs/install.log
|
||||
logs/server-logs/
|
||||
logs/playwright
|
||||
logs/playwright-permissions-*
|
||||
logs/playwright_extra
|
||||
logs/playwright_ime
|
||||
logs/studio-permissions-*.log
|
||||
retention-days: 7
|
||||
|
|
|
|||
12
.github/workflows/studio-update-smoke.yml
vendored
12
.github/workflows/studio-update-smoke.yml
vendored
|
|
@ -9,7 +9,7 @@
|
|||
# This catches regressions in setup.sh's update path that the existing
|
||||
# GGUF / wheel jobs would miss because they only invoke install.sh once.
|
||||
|
||||
name: Studio Update CI
|
||||
name: Unsloth Update CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
|
@ -36,7 +36,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
update-idempotency:
|
||||
name: Studio Updating Tests
|
||||
name: Unsloth Updating Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
|
|
@ -63,7 +63,7 @@ jobs:
|
|||
# post-step then fatal-errors with "Cache folder path is
|
||||
# retrieved for pip but doesn't exist on disk".
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
# Pass the workflow token so the llama.cpp prebuilt installer's
|
||||
# GitHub-API call to list releases isn't rate-limited (60/hr
|
||||
# unauthenticated). Without this, three consecutive install +
|
||||
|
|
@ -122,7 +122,7 @@ jobs:
|
|||
grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log
|
||||
echo "second update was clean"
|
||||
|
||||
- name: Boot Studio briefly to confirm the install is still usable
|
||||
- name: Boot Unsloth briefly to confirm the install is still usable
|
||||
# If `update --local` accidentally broke the venv or wiped the
|
||||
# llama-server binary, the server would fail to start here.
|
||||
run: |
|
||||
|
|
@ -138,13 +138,13 @@ jobs:
|
|||
sleep 1
|
||||
done
|
||||
if ! jq -e '.status == "healthy"' /tmp/health.json 2>/dev/null; then
|
||||
echo "Studio failed to come up after `update`"
|
||||
echo "Unsloth failed to come up after `update`"
|
||||
tail -200 logs/studio.log
|
||||
kill "$PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
kill "$PID" 2>/dev/null || true
|
||||
echo "post-update Studio /api/health OK"
|
||||
echo "post-update Unsloth /api/health OK"
|
||||
|
||||
- name: Uninstall and verify clean
|
||||
# Round-trip the installer through scripts/uninstall.sh: confirms the
|
||||
|
|
|
|||
16
.github/workflows/studio-windows-api-smoke.yml
vendored
16
.github/workflows/studio-windows-api-smoke.yml
vendored
|
|
@ -9,7 +9,7 @@
|
|||
# (Section 6) is Linux-only and short-circuits on non-POSIX; the rest
|
||||
# is platform-portable.
|
||||
|
||||
name: Windows Studio API CI
|
||||
name: Windows Unsloth API CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
|
@ -34,7 +34,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
api-smoke:
|
||||
name: Studio API & Auth Tests
|
||||
name: Unsloth API & Auth Tests
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
|
|
@ -105,7 +105,7 @@ jobs:
|
|||
# studio-windows-update-smoke.yml for the full rationale --
|
||||
# creating an empty studio/frontend/dist trips setup.ps1's
|
||||
# mtime-based staleness check into "frontend up to date, skip
|
||||
# rebuild" and Studio boots with an empty dist directory.
|
||||
# rebuild" and Unsloth boots with an empty dist directory.
|
||||
# Add-MpPreference accepts paths that do not yet exist.
|
||||
foreach ($p in @(
|
||||
"$env:USERPROFILE\.unsloth",
|
||||
|
|
@ -121,7 +121,7 @@ jobs:
|
|||
}
|
||||
}
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -161,7 +161,7 @@ jobs:
|
|||
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
|
||||
cat "$INFO"
|
||||
|
||||
- name: Add Studio shim to GITHUB_PATH
|
||||
- name: Add Unsloth shim to GITHUB_PATH
|
||||
# install.ps1's User-PATH update doesn't propagate to a
|
||||
# running Git Bash session; export the shim dir so the
|
||||
# next `unsloth ...` invocation finds it.
|
||||
|
|
@ -177,7 +177,7 @@ jobs:
|
|||
- name: Install pyjwt for the JWT-expiry forge test
|
||||
run: python -m pip install 'pyjwt>=2.6'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
|
|
@ -207,7 +207,7 @@ jobs:
|
|||
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run Studio API & Auth tests
|
||||
- name: Run Unsloth API & Auth tests
|
||||
# Do NOT pin STUDIO_AUTH_DIR here. The Mac/Linux mirrors
|
||||
# hardcode runner-specific paths (/Users/runner/...,
|
||||
# /home/runner/...), but on Windows the path is
|
||||
|
|
@ -219,7 +219,7 @@ jobs:
|
|||
BASE_URL: http://127.0.0.1:18895
|
||||
run: python tests/studio/studio_api_smoke.py
|
||||
|
||||
- name: Stop Studio
|
||||
- name: Stop Unsloth
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Three end-to-end smoke jobs that boot a freshly-installed Studio and
|
||||
# Three end-to-end smoke jobs that boot a freshly-installed Unsloth and
|
||||
# exercise the surfaces real users hit through the OpenAI / Anthropic
|
||||
# SDKs and curl, on the FREE windows-latest runner. Each job picks the
|
||||
# smallest model that exercises the behaviour under test, primes
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
# Qwen3-VL-2B-Instruct UD-IQ2_XXS + mmproj-F16 (~1.4 GiB total).
|
||||
# Within the 14 GB windows-latest SSD budget.
|
||||
|
||||
name: Windows Studio GGUF CI
|
||||
name: Windows Unsloth GGUF CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
STUDIO_PORT: '18888'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
# Force UTF-8 for stdio (Windows defaults to cp1252; hf
|
||||
# download / Studio CLI print "✓" checkmarks and crash
|
||||
# download / Unsloth CLI print "✓" checkmarks and crash
|
||||
# otherwise).
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: '1'
|
||||
|
|
@ -160,7 +160,7 @@ jobs:
|
|||
# studio-windows-update-smoke.yml for the full rationale --
|
||||
# creating an empty studio/frontend/dist trips setup.ps1's
|
||||
# mtime-based staleness check into "frontend up to date, skip
|
||||
# rebuild" and Studio boots with an empty dist directory.
|
||||
# rebuild" and Unsloth boots with an empty dist directory.
|
||||
# Add-MpPreference accepts paths that do not yet exist.
|
||||
foreach ($p in @(
|
||||
"$env:USERPROFILE\.unsloth",
|
||||
|
|
@ -176,7 +176,7 @@ jobs:
|
|||
}
|
||||
}
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -214,7 +214,7 @@ jobs:
|
|||
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
|
||||
cat "$INFO"
|
||||
|
||||
- name: Add Studio shim to GITHUB_PATH
|
||||
- name: Add Unsloth shim to GITHUB_PATH
|
||||
run: |
|
||||
SHIM_DIR=~/.unsloth/studio/bin
|
||||
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
|
||||
|
|
@ -227,7 +227,7 @@ jobs:
|
|||
- name: Install OpenAI + Anthropic Python SDKs
|
||||
run: python -m pip install 'openai>=1.50' 'anthropic>=0.40'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
|
|
@ -244,7 +244,7 @@ jobs:
|
|||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Studio did not become healthy in 180s"
|
||||
echo "Unsloth did not become healthy in 180s"
|
||||
tail -200 logs/studio.log
|
||||
exit 1
|
||||
|
||||
|
|
@ -281,7 +281,7 @@ jobs:
|
|||
# Retry the load step a few times so a transient TCP RST during
|
||||
# llama-server warm-up (Windows runner image churn,
|
||||
# windows-latest -> windows-2025-vs2026 rollout) doesn't fail
|
||||
# the whole job. The Studio backend's _wait_for_health now
|
||||
# the whole job. The Unsloth backend's _wait_for_health now
|
||||
# catches httpx.ReadError too; this retry layer covers the
|
||||
# cases the backend can't recover from on its own.
|
||||
LOAD_OK=0
|
||||
|
|
@ -382,15 +382,15 @@ jobs:
|
|||
print(f"[{label}] OK -- 4 turns, run1 == run2, history grounded")
|
||||
PY
|
||||
|
||||
- name: Stop Studio
|
||||
- name: Stop Unsloth
|
||||
if: always()
|
||||
# Run as cmd so we are not running through the Git Bash shell;
|
||||
# Git Bash on windows-latest has been observed to exit 143
|
||||
# (SIGTERM) from any inline kill/sleep block, masking a green
|
||||
# test run. The runner reclaims the Studio child process at
|
||||
# test run. The runner reclaims the Unsloth child process at
|
||||
# job end either way, so just emit a marker and exit 0.
|
||||
shell: cmd
|
||||
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
|
||||
run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
|
||||
|
||||
- name: Collect llama-server logs
|
||||
if: always()
|
||||
|
|
@ -398,10 +398,10 @@ jobs:
|
|||
# copy must not fail an otherwise-green job.
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
# Copy llama-server's own stdout/stderr (teed by Studio under
|
||||
# Copy llama-server's own stdout/stderr (teed by Unsloth under
|
||||
# ~/.unsloth/studio/logs/llama-server/) into the workspace so
|
||||
# upload-artifact can pick it up. Crucial for diagnosing a
|
||||
# subprocess crash where Studio's traceback only shows the
|
||||
# subprocess crash where Unsloth's traceback only shows the
|
||||
# symptom (httpx ReadError) but not the cause.
|
||||
run: |
|
||||
mkdir -p logs/llama-server
|
||||
|
|
@ -439,14 +439,14 @@ jobs:
|
|||
# (211 s on first run; subsequent runs hit the cache, but the
|
||||
# one-time cost recurs every time the cache key bumps). Use
|
||||
# main's `--local-dir gguf-cache` pattern: cache the flat .gguf
|
||||
# only, pass an absolute path to Studio's /api/inference/load.
|
||||
# only, pass an absolute path to Unsloth's /api/inference/load.
|
||||
# The OpenAI/Anth and JSON+images jobs still cover the
|
||||
# gguf_variant resolution path.
|
||||
GGUF_REPO: unsloth/Qwen3.5-2B-GGUF
|
||||
GGUF_FILE: Qwen3.5-2B-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18898'
|
||||
# Force UTF-8 for stdio (Windows defaults to cp1252; hf
|
||||
# download / Studio CLI print "✓" checkmarks and crash
|
||||
# download / Unsloth CLI print "✓" checkmarks and crash
|
||||
# otherwise).
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: '1'
|
||||
|
|
@ -507,7 +507,7 @@ jobs:
|
|||
# studio-windows-update-smoke.yml for the full rationale --
|
||||
# creating an empty studio/frontend/dist trips setup.ps1's
|
||||
# mtime-based staleness check into "frontend up to date, skip
|
||||
# rebuild" and Studio boots with an empty dist directory.
|
||||
# rebuild" and Unsloth boots with an empty dist directory.
|
||||
# Add-MpPreference accepts paths that do not yet exist.
|
||||
foreach ($p in @(
|
||||
"$env:USERPROFILE\.unsloth",
|
||||
|
|
@ -523,7 +523,7 @@ jobs:
|
|||
}
|
||||
}
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -561,7 +561,7 @@ jobs:
|
|||
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
|
||||
cat "$INFO"
|
||||
|
||||
- name: Add Studio shim to GITHUB_PATH
|
||||
- name: Add Unsloth shim to GITHUB_PATH
|
||||
run: |
|
||||
SHIM_DIR=~/.unsloth/studio/bin
|
||||
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
|
||||
|
|
@ -571,7 +571,7 @@ jobs:
|
|||
fi
|
||||
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Reset auth + boot Studio (API-only, default tool policy)
|
||||
- name: Reset auth + boot Unsloth (API-only, default tool policy)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
|
|
@ -607,7 +607,7 @@ jobs:
|
|||
# raw string, but we cannot embed `\a` etc. in JSON without
|
||||
# JSON-string-escaping every backslash. Replace `\` with `/`
|
||||
# via bash parameter expansion -- pathlib.Path on Windows
|
||||
# accepts forward slashes natively, so Studio's loader sees
|
||||
# accepts forward slashes natively, so Unsloth's loader sees
|
||||
# a normal path.
|
||||
GGUF_PATH="${GITHUB_WORKSPACE//\\//}/gguf-cache/${GGUF_FILE}"
|
||||
ls -lh "$GGUF_PATH"
|
||||
|
|
@ -680,7 +680,7 @@ jobs:
|
|||
def post_sse(path, body, *, timeout = 600, retries = 1, soft = False):
|
||||
# The server-side agentic loop always answers over SSE. A
|
||||
# shared CI runner can stall the stream transport (the
|
||||
# connection opening, or a mid-stream read) even when Studio
|
||||
# connection opening, or a mid-stream read) even when Unsloth
|
||||
# is healthy, so harden the read three ways:
|
||||
# * retry a transport stall once with a fresh request,
|
||||
# capped at 300s (a healthy server answers a retry
|
||||
|
|
@ -791,6 +791,7 @@ jobs:
|
|||
content = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}],
|
||||
"enable_tools": True,
|
||||
"permission_mode": "full",
|
||||
"enabled_tools": ["python"],
|
||||
"session_id": "ci-tool-calling-py",
|
||||
"temperature": TEMP,
|
||||
|
|
@ -816,6 +817,7 @@ jobs:
|
|||
content = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "Use the terminal tool to run `echo hello-bash-tool` and tell me the exact output."}],
|
||||
"enable_tools": True,
|
||||
"permission_mode": "full",
|
||||
"enabled_tools": ["terminal"],
|
||||
"session_id": "ci-tool-calling-bash",
|
||||
"temperature": TEMP,
|
||||
|
|
@ -840,6 +842,7 @@ jobs:
|
|||
content = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
|
||||
"enable_tools": True,
|
||||
"permission_mode": "full",
|
||||
"enabled_tools": ["web_search"],
|
||||
"session_id": "ci-tool-calling-web",
|
||||
"temperature": TEMP,
|
||||
|
|
@ -879,15 +882,15 @@ jobs:
|
|||
print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)")
|
||||
PY
|
||||
|
||||
- name: Stop Studio
|
||||
- name: Stop Unsloth
|
||||
if: always()
|
||||
# Run as cmd so we are not running through the Git Bash shell;
|
||||
# Git Bash on windows-latest has been observed to exit 143
|
||||
# (SIGTERM) from any inline kill/sleep block, masking a green
|
||||
# test run. The runner reclaims the Studio child process at
|
||||
# test run. The runner reclaims the Unsloth child process at
|
||||
# job end either way, so just emit a marker and exit 0.
|
||||
shell: cmd
|
||||
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
|
||||
run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
|
||||
|
||||
- name: Collect llama-server logs
|
||||
if: always()
|
||||
|
|
@ -895,10 +898,10 @@ jobs:
|
|||
# copy must not fail an otherwise-green job.
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
# Copy llama-server's own stdout/stderr (teed by Studio under
|
||||
# Copy llama-server's own stdout/stderr (teed by Unsloth under
|
||||
# ~/.unsloth/studio/logs/llama-server/) into the workspace so
|
||||
# upload-artifact can pick it up. Crucial for diagnosing a
|
||||
# subprocess crash where Studio's traceback only shows the
|
||||
# subprocess crash where Unsloth's traceback only shows the
|
||||
# symptom (httpx ReadError) but not the cause.
|
||||
run: |
|
||||
mkdir -p logs/llama-server
|
||||
|
|
@ -936,7 +939,7 @@ jobs:
|
|||
STUDIO_PORT: '18899'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
# Force UTF-8 for stdio (Windows defaults to cp1252; hf
|
||||
# download / Studio CLI print "✓" checkmarks and crash
|
||||
# download / Unsloth CLI print "✓" checkmarks and crash
|
||||
# otherwise).
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: '1'
|
||||
|
|
@ -1002,7 +1005,7 @@ jobs:
|
|||
# studio-windows-update-smoke.yml for the full rationale --
|
||||
# creating an empty studio/frontend/dist trips setup.ps1's
|
||||
# mtime-based staleness check into "frontend up to date, skip
|
||||
# rebuild" and Studio boots with an empty dist directory.
|
||||
# rebuild" and Unsloth boots with an empty dist directory.
|
||||
# Add-MpPreference accepts paths that do not yet exist.
|
||||
foreach ($p in @(
|
||||
"$env:USERPROFILE\.unsloth",
|
||||
|
|
@ -1018,7 +1021,7 @@ jobs:
|
|||
}
|
||||
}
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -1056,7 +1059,7 @@ jobs:
|
|||
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
|
||||
cat "$INFO"
|
||||
|
||||
- name: Add Studio shim to GITHUB_PATH
|
||||
- name: Add Unsloth shim to GITHUB_PATH
|
||||
run: |
|
||||
SHIM_DIR=~/.unsloth/studio/bin
|
||||
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
|
||||
|
|
@ -1069,7 +1072,7 @@ jobs:
|
|||
- name: Install OpenAI + Anthropic Python SDKs
|
||||
run: python -m pip install 'openai>=1.50' 'anthropic>=0.40'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
|
|
@ -1259,7 +1262,7 @@ jobs:
|
|||
except Exception as exc:
|
||||
print(
|
||||
f"[image/openai] WARN image_url SDK call raised: {type(exc).__name__}: "
|
||||
f"{exc}. Studio successfully forwarded the request; failure here is "
|
||||
f"{exc}. Unsloth successfully forwarded the request; failure here is "
|
||||
f"upstream llama.cpp vision behaviour."
|
||||
)
|
||||
|
||||
|
|
@ -1300,19 +1303,19 @@ jobs:
|
|||
print(
|
||||
f"[image/anthropic] WARN anthropic image SDK call raised: "
|
||||
f"{type(exc).__name__}: {exc}. Likely upstream llama.cpp vision "
|
||||
f"behaviour, NOT a Studio regression."
|
||||
f"behaviour, NOT an Unsloth regression."
|
||||
)
|
||||
PY
|
||||
|
||||
- name: Stop Studio
|
||||
- name: Stop Unsloth
|
||||
if: always()
|
||||
# Run as cmd so we are not running through the Git Bash shell;
|
||||
# Git Bash on windows-latest has been observed to exit 143
|
||||
# (SIGTERM) from any inline kill/sleep block, masking a green
|
||||
# test run. The runner reclaims the Studio child process at
|
||||
# test run. The runner reclaims the Unsloth child process at
|
||||
# job end either way, so just emit a marker and exit 0.
|
||||
shell: cmd
|
||||
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
|
||||
run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
|
||||
|
||||
- name: Collect llama-server logs
|
||||
if: always()
|
||||
|
|
@ -1320,10 +1323,10 @@ jobs:
|
|||
# copy must not fail an otherwise-green job.
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
# Copy llama-server's own stdout/stderr (teed by Studio under
|
||||
# Copy llama-server's own stdout/stderr (teed by Unsloth under
|
||||
# ~/.unsloth/studio/logs/llama-server/) into the workspace so
|
||||
# upload-artifact can pick it up. Crucial for diagnosing a
|
||||
# subprocess crash where Studio's traceback only shows the
|
||||
# subprocess crash where Unsloth's traceback only shows the
|
||||
# symptom (httpx ReadError) but not the cause.
|
||||
run: |
|
||||
mkdir -p logs/llama-server
|
||||
|
|
@ -1345,7 +1348,7 @@ jobs:
|
|||
|
||||
# ── folded from studio-windows-no-vs-smoke.yml: install + run with no Visual Studio ──
|
||||
no-vs-cpu:
|
||||
name: Studio install + inference without Visual Studio
|
||||
name: Unsloth install + inference without Visual Studio
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 35
|
||||
defaults:
|
||||
|
|
@ -1499,7 +1502,7 @@ jobs:
|
|||
python -m pip install torch --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple
|
||||
python -c "import torch; print('torch', torch.__version__, 'cuda?', torch.cuda.is_available())"
|
||||
|
||||
- name: Install Studio (--local, --no-torch) with no build tools present
|
||||
- name: Install Unsloth (--local, --no-torch) with no build tools present
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -1535,13 +1538,13 @@ jobs:
|
|||
echo "Prebuilt installed with no build tools:"
|
||||
cat "$INFO"
|
||||
|
||||
- name: Add Studio shim to GITHUB_PATH
|
||||
- name: Add Unsloth shim to GITHUB_PATH
|
||||
run: |
|
||||
SHIM_DIR=~/.unsloth/studio/bin
|
||||
[ -f "$SHIM_DIR/unsloth.exe" ] || { echo "::error::unsloth.exe shim not found"; ls -la ~/.unsloth/studio/ || true; exit 1; }
|
||||
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
|
|
@ -1610,10 +1613,10 @@ jobs:
|
|||
}
|
||||
Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
- name: Stop Studio
|
||||
- name: Stop Unsloth
|
||||
if: always()
|
||||
shell: cmd
|
||||
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
|
||||
run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
|
||||
|
||||
- name: Collect llama-server logs
|
||||
if: always()
|
||||
|
|
|
|||
39
.github/workflows/studio-windows-ui-smoke.yml
vendored
39
.github/workflows/studio-windows-ui-smoke.yml
vendored
|
|
@ -4,11 +4,11 @@
|
|||
# Windows counterpart to studio-ui-smoke.yml / studio-mac-ui-smoke.yml.
|
||||
# Same Playwright + Chromium end-to-end chat UI flow + extra UI flow,
|
||||
# but on the FREE windows-latest runner so we catch Windows-specific
|
||||
# regressions in the install path (install.ps1), the Studio CLI's
|
||||
# regressions in the install path (install.ps1), the Unsloth CLI's
|
||||
# Windows process-management branches, and the llama.cpp prebuilt's
|
||||
# Windows HTTP layer.
|
||||
|
||||
name: Windows Studio UI CI
|
||||
name: Windows Unsloth UI CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
|
@ -19,6 +19,7 @@ on:
|
|||
- 'install.ps1'
|
||||
- 'pyproject.toml'
|
||||
- 'tests/studio/**'
|
||||
- '.github/scripts/run-studio-permission-browser.sh'
|
||||
- '.github/workflows/studio-windows-ui-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
|
|
@ -49,7 +50,7 @@ jobs:
|
|||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18896'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
# Force UTF-8 for stdio so Python tools (hf download, Studio
|
||||
# Force UTF-8 for stdio so Python tools (hf download, Unsloth
|
||||
# CLI, etc.) can print Unicode characters like the success
|
||||
# checkmark "✓". Windows defaults to cp1252 / charmap and
|
||||
# any tool that prints "OK ✓" hits a UnicodeEncodeError.
|
||||
|
|
@ -121,7 +122,7 @@ jobs:
|
|||
# studio-windows-update-smoke.yml for the full rationale --
|
||||
# creating an empty studio/frontend/dist trips setup.ps1's
|
||||
# mtime-based staleness check into "frontend up to date, skip
|
||||
# rebuild" and Studio boots with an empty dist directory.
|
||||
# rebuild" and Unsloth boots with an empty dist directory.
|
||||
# Add-MpPreference accepts paths that do not yet exist.
|
||||
foreach ($p in @(
|
||||
"$env:USERPROFILE\.unsloth",
|
||||
|
|
@ -148,7 +149,7 @@ jobs:
|
|||
Set-Content -LiteralPath (Join-Path $appDir 'launch-studio.vbs') -Value 'WScript.Echo "legacy"' -Encoding Unicode
|
||||
Write-Host "seeded legacy launch-studio.vbs at $appDir"
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
# install.ps1 is the supported Windows installer. install.sh
|
||||
# has no Windows branch (apt-get / brew calls). The PS1
|
||||
# script's `Install-UnslothStudio @args` line at the bottom
|
||||
|
|
@ -205,7 +206,7 @@ jobs:
|
|||
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
|
||||
cat "$INFO"
|
||||
|
||||
- name: Assert Studio launcher chain (no VBS, hidden PowerShell shortcut)
|
||||
- name: Assert Unsloth launcher chain (no VBS, hidden PowerShell shortcut)
|
||||
# The shortcut launch path is otherwise untested here (the steps below
|
||||
# boot `unsloth studio` directly). Guard against re-introducing the VBS
|
||||
# that tripped Kaspersky HEUR:Trojan.VBS.Agent.gen and against the .lnk
|
||||
|
|
@ -234,7 +235,7 @@ jobs:
|
|||
}
|
||||
Write-Host "launcher chain OK (no VBS; hidden powershell over launch-studio.ps1)"
|
||||
|
||||
- name: Launch Studio via the shortcut and assert health
|
||||
- name: Launch Unsloth via the shortcut and assert health
|
||||
# Run the exact command the .lnk stores (hidden PowerShell over
|
||||
# launch-studio.ps1) and confirm it brings the backend up. This is the
|
||||
# only step that proves the shortcut launch is not silently broken.
|
||||
|
|
@ -265,10 +266,10 @@ jobs:
|
|||
$owner = (Get-NetTCPConnection -LocalPort $foundPort -State Listen -ErrorAction Stop | Select-Object -First 1).OwningProcess
|
||||
if ($owner) { taskkill /PID $owner /T /F 2>$null | Out-Null }
|
||||
} catch {}
|
||||
if (-not $foundPort) { throw "Studio did not become healthy when launched via the shortcut" }
|
||||
Write-Host "Studio healthy on port $foundPort (launched via the shortcut)"
|
||||
if (-not $foundPort) { throw "Unsloth did not become healthy when launched via the shortcut" }
|
||||
Write-Host "Unsloth healthy on port $foundPort (launched via the shortcut)"
|
||||
|
||||
- name: Add Studio shim to GITHUB_PATH
|
||||
- name: Add Unsloth shim to GITHUB_PATH
|
||||
# install.ps1 puts unsloth.exe at $StudioHome\bin\unsloth.exe
|
||||
# and adds that dir to the User PATH via the Windows registry.
|
||||
# Registry-level PATH updates don't propagate to a running
|
||||
|
|
@ -284,7 +285,7 @@ jobs:
|
|||
fi
|
||||
# GITHUB_PATH wants Windows-style paths; convert via cygpath.
|
||||
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
|
||||
echo "Added Studio shim dir to PATH: $(cygpath -w "$SHIM_DIR")"
|
||||
echo "Added Unsloth shim dir to PATH: $(cygpath -w "$SHIM_DIR")"
|
||||
|
||||
- name: Install Playwright + Chromium
|
||||
# No --with-deps on Windows: that flag installs Linux apt
|
||||
|
|
@ -294,7 +295,7 @@ jobs:
|
|||
python -m pip install 'playwright>=1.45'
|
||||
python -m playwright install chromium
|
||||
|
||||
- name: Reset auth + boot Studio
|
||||
- name: Reset auth + boot Unsloth
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
|
|
@ -339,13 +340,17 @@ jobs:
|
|||
mkdir -p logs/playwright
|
||||
python tests/studio/playwright_chat_ui.py
|
||||
|
||||
- name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders)
|
||||
- name: Stop Unsloth (chat-ui ends with Shutdown click; this is belt-and-suspenders)
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Reset auth + boot Studio for extra UI tests (port 18897)
|
||||
- name: Edge permission controls
|
||||
run: |
|
||||
bash .github/scripts/run-studio-permission-browser.sh 18895 chromium msedge
|
||||
|
||||
- name: Reset auth + boot Unsloth for extra UI tests (port 18897)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
|
|
@ -372,7 +377,7 @@ jobs:
|
|||
echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Drive Compare/Recipes/Export/Studio/Settings with Playwright
|
||||
- name: Drive Compare/Recipes/Export/Unsloth/Settings with Playwright
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18897
|
||||
STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }}
|
||||
|
|
@ -386,7 +391,7 @@ jobs:
|
|||
mkdir -p logs/playwright_extra
|
||||
python tests/studio/playwright_extra_ui.py
|
||||
|
||||
- name: Stop second Studio
|
||||
- name: Stop second Unsloth
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
|
||||
|
|
@ -402,5 +407,7 @@ jobs:
|
|||
logs/studio_extra.log
|
||||
logs/install.log
|
||||
logs/playwright
|
||||
logs/playwright-permissions-*
|
||||
logs/playwright_extra
|
||||
logs/studio-permissions-*.log
|
||||
retention-days: 7
|
||||
|
|
|
|||
|
|
@ -5,19 +5,19 @@
|
|||
# studio-mac-update-smoke.yml. Verifies that on the FREE
|
||||
# windows-latest runner:
|
||||
#
|
||||
# 1. install.ps1 --local --no-torch installs Studio AND auto-fetches
|
||||
# 1. install.ps1 --local --no-torch installs Unsloth AND auto-fetches
|
||||
# the prebuilt llama.cpp Windows binary (app-<tag>-windows-x64-cpu
|
||||
# from unslothai/llama.cpp). Hitting the source-build fallback is
|
||||
# treated as an Unsloth bug -- Studio must always pick the
|
||||
# treated as an Unsloth bug -- Unsloth must always pick the
|
||||
# prebuilt on Windows.
|
||||
# 2. unsloth studio update --local is idempotent. Two consecutive
|
||||
# runs both report "prebuilt up to date and validated", no
|
||||
# source-build fallback. The CLI's _find_setup_script picks
|
||||
# setup.ps1 on Windows automatically.
|
||||
# 3. The installed Studio still boots and /api/health returns
|
||||
# 3. The installed Unsloth still boots and /api/health returns
|
||||
# healthy after the update path.
|
||||
|
||||
name: Windows Studio Update CI
|
||||
name: Windows Unsloth Update CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
|
@ -45,7 +45,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
update-idempotency:
|
||||
name: Studio Updating Tests
|
||||
name: Unsloth Updating Tests
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
|
|
@ -53,7 +53,7 @@ jobs:
|
|||
shell: bash
|
||||
env:
|
||||
# Force UTF-8 for stdio (Windows defaults to cp1252; hf
|
||||
# download / Studio CLI print "✓" checkmarks and crash
|
||||
# download / Unsloth CLI print "✓" checkmarks and crash
|
||||
# otherwise).
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: '1'
|
||||
|
|
@ -90,7 +90,7 @@ jobs:
|
|||
# reuses the existing Node with no download.
|
||||
#
|
||||
# (2) Defender. windows-latest's real-time scan opens / hashes
|
||||
# every file Studio writes during install (Vite output =
|
||||
# every file Unsloth writes during install (Vite output =
|
||||
# thousands of small chunks, uv pip = wheel-extraction =
|
||||
# thousands of small files). The latency dominates the
|
||||
# 200 s frontend build and the 90 s deps install. Adding
|
||||
|
|
@ -109,7 +109,7 @@ jobs:
|
|||
# setup.ps1 line 1281-1296's mtime-based "is the frontend
|
||||
# stale?" check into "up to date, skip rebuild", because the
|
||||
# newly-created dist's mtime is younger than every source
|
||||
# file. Studio then boots with an empty dist and 500s on
|
||||
# file. Unsloth then boots with an empty dist and 500s on
|
||||
# GET / with FileNotFoundError: dist\index.html. See run
|
||||
# 25546676715 / job 74984469728.
|
||||
# Add-MpPreference accepts paths that do not yet exist; the
|
||||
|
|
@ -129,7 +129,7 @@ jobs:
|
|||
}
|
||||
}
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
- name: Install Unsloth (--local, --no-torch)
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -168,7 +168,7 @@ jobs:
|
|||
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
|
||||
cat "$INFO"
|
||||
|
||||
- name: Add Studio shim to GITHUB_PATH
|
||||
- name: Add Unsloth shim to GITHUB_PATH
|
||||
run: |
|
||||
SHIM_DIR=~/.unsloth/studio/bin
|
||||
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
|
||||
|
|
@ -212,7 +212,7 @@ jobs:
|
|||
grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log
|
||||
echo "second update was clean"
|
||||
|
||||
- name: Boot Studio briefly to confirm the install is still usable
|
||||
- name: Boot Unsloth briefly to confirm the install is still usable
|
||||
run: |
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18891 \
|
||||
|
|
@ -239,13 +239,13 @@ jobs:
|
|||
sleep 1
|
||||
done
|
||||
if [ -z "$HEALTHY" ]; then
|
||||
echo "Studio failed to come up after \`update\`"
|
||||
echo "Unsloth failed to come up after \`update\`"
|
||||
tail -200 logs/studio.log
|
||||
kill "$PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
kill "$PID" 2>/dev/null || true
|
||||
echo "post-update Studio /api/health OK"
|
||||
echo "post-update Unsloth /api/health OK"
|
||||
|
||||
- name: Uninstall and verify clean
|
||||
# Round-trip through scripts/uninstall.ps1 against the default
|
||||
|
|
|
|||
10
.github/workflows/wheel-smoke.yml
vendored
10
.github/workflows/wheel-smoke.yml
vendored
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
# Builds the PyPI wheel from the PR branch, then verifies the built wheel
|
||||
# actually contains what we expect to ship and does NOT contain the broken
|
||||
# Studio bundle that 2026.5.1 published. This is the single workflow that
|
||||
# Unsloth bundle that 2026.5.1 published. This is the single workflow that
|
||||
# would have blocked the 2026.5.1 release before twine upload.
|
||||
#
|
||||
# Verified locally end-to-end against this branch:
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
# lockfile shipped, frontend dist shipped,
|
||||
# no node_modules in wheel, no bun.lock in wheel,
|
||||
# main bundle has unstable_Provider hits=1 (assistant-ui internals only).
|
||||
# - Studio backend imports cleanly from the installed wheel with the
|
||||
# - Unsloth backend imports cleanly from the installed wheel with the
|
||||
# lightweight dep set below.
|
||||
|
||||
name: Wheel CI
|
||||
|
|
@ -101,7 +101,7 @@ jobs:
|
|||
hits = data.count("unstable_Provider:")
|
||||
print(f"main bundle: {js[0]}")
|
||||
print(f"unstable_Provider hits: {hits} (>=4 indicates 2026.5.1 regression)")
|
||||
checks["bundle has no Studio unstable_Provider call site"] = (hits < 4)
|
||||
checks["bundle has no Unsloth unstable_Provider call site"] = (hits < 4)
|
||||
|
||||
print()
|
||||
for k, v in checks.items():
|
||||
|
|
@ -109,7 +109,7 @@ jobs:
|
|||
sys.exit(0 if all(checks.values()) else 1)
|
||||
PY
|
||||
|
||||
- name: Studio backend import smoke
|
||||
- name: Unsloth backend import smoke
|
||||
# Imports `studio.backend.main:app` from the freshly-installed wheel in
|
||||
# a clean venv. This catches the class of bug that 2026.5.1 shipped with:
|
||||
# frontend dist missing, package-lock.json missing, or the wheel's Python
|
||||
|
|
@ -125,7 +125,7 @@ jobs:
|
|||
/tmp/v/bin/pip install --no-deps dist/unsloth-*.whl
|
||||
# Run from /tmp so Python imports the installed package, not the source tree.
|
||||
cd /tmp
|
||||
/tmp/v/bin/python -c "from studio.backend.main import app; print('Studio backend OK:', app.title)"
|
||||
/tmp/v/bin/python -c "from studio.backend.main import app; print('Unsloth backend OK:', app.title)"
|
||||
|
||||
- name: Upload wheel on failure
|
||||
if: failure()
|
||||
|
|
|
|||
76
README.md
76
README.md
|
|
@ -11,6 +11,7 @@ Unsloth Studio lets you run and train models locally.
|
|||
|
||||
<p align="center">
|
||||
<a href="#-features">Features</a> •
|
||||
<a href="#-unsloth-news">News</a> •
|
||||
<a href="#-install">Quickstart</a> •
|
||||
<a href="#-free-notebooks">Notebooks</a> •
|
||||
<a href="https://unsloth.ai/docs">Documentation</a>
|
||||
|
|
@ -47,15 +48,44 @@ Unsloth Studio (Beta) lets you run and train text, [audio](https://unsloth.ai/do
|
|||
* [Auto set inference settings](https://unsloth.ai/docs/new/studio/chat#auto-parameter-tuning) and customize chat templates.
|
||||
* We work directly with teams behind [gpt-oss](https://docs.unsloth.ai/new/gpt-oss-how-to-run-and-fine-tune#unsloth-fixes-for-gpt-oss), [Qwen3](https://www.reddit.com/r/LocalLLaMA/comments/1kaodxu/qwen3_unsloth_dynamic_ggufs_128k_context_bug_fixes/), [Llama 4](https://github.com/ggml-org/llama.cpp/pull/12889), [Mistral](https://huggingface.co/mistralai/Mistral-Medium-3.5-128B/discussions/18), [Gemma 1-3](https://news.ycombinator.com/item?id=39671146), and [Phi-4](https://unsloth.ai/blog/phi4), where we’ve fixed bugs that improve model accuracy.
|
||||
* Chat with images, audio, PDFs, code, DOCX and more. [Connect API providers](https://unsloth.ai/docs/integrations/connections) (OpenAI, Anthropic) or servers (vLLM, Ollama).
|
||||
* [**Compare any two models**](https://unsloth.ai/docs/new/studio/chat#model-arena) side by side with the same prompt.
|
||||
* **OpenAI/Anthropic-compatible APIs**: Serve local models through `/v1/chat/completions`, `/v1/responses` and `/v1/messages`.
|
||||
* **Connect local models to agents**: Use `unsloth start` with Claude Code, Codex, Hermes and more.
|
||||
* **Web/PDF search** can read PDF papers, manuals and other PDF results.
|
||||
* **GGUF hardware controls**: Choose GPUs/layers, offload MoE experts, use multi-GPU or Tensor Parallelism.
|
||||
* The opt-in **MCP control endpoint** lets AI clients manage models, training, recipes and exports.
|
||||
### Training
|
||||
* Train and RL **500+ models** up to **2x faster** with up to **70% less VRAM**, with no accuracy loss.
|
||||
* Custom Triton and mathematical **kernels**. See some collabs we did with [PyTorch](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) and [Hugging Face](https://unsloth.ai/docs/new/faster-moe).
|
||||
* Train and RL **500+ models** up to **2x faster** with **70% less VRAM**; MoE up to **12x faster**.
|
||||
* Train and run RL on [AMD GPUs](https://unsloth.ai/docs/basics/amd) across Windows, WSL and Linux.
|
||||
* **Data Recipes**: [Auto-create datasets](https://unsloth.ai/docs/new/studio/data-recipe) from **PDF, CSV, DOCX** etc. Edit data in a visual-node workflow.
|
||||
* **[Reinforcement Learning](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide)** (RL): The most efficient [RL](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide) library, using **80% less VRAM** for GRPO, [FP8](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) etc.
|
||||
* Supports full fine-tuning, RL, pretraining, 4-bit, 16-bit and, FP8 training.
|
||||
* **[Reinforcement Learning](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide)** uses **80% less VRAM** for GRPO, FP8 and vision RL, with 7x longer contexts.
|
||||
* [**Long-context training**](https://unsloth.ai/docs/new/3x-faster-training-packing): **3x faster**, 30% less VRAM and 500K+ context.
|
||||
* Supports LoRA/QLoRA, full fine-tuning, RL, pretraining, 4-bit, 16-bit and FP8.
|
||||
* Custom Triton and mathematical **kernels** built with PyTorch and Hugging Face.
|
||||
* **Observability**: Monitor training live, track loss and GPU usage and customize graphs.
|
||||
* [Multi-GPU](https://unsloth.ai/docs/basics/multi-gpu-training-with-unsloth) training is supported, with major improvements coming soon.
|
||||
|
||||
## 🚀 Unsloth Start
|
||||
|
||||
[Unsloth Start](https://unsloth.ai/docs/integrations/unsloth-start) connects [Claude Code](https://unsloth.ai/docs/basics/claude-code), [Codex](https://unsloth.ai/docs/basics/codex) and other agents to local models with one command.
|
||||
|
||||
Start Unsloth, load a model, open your project folder, then run:
|
||||
|
||||
```bash
|
||||
unsloth start claude
|
||||
```
|
||||
|
||||
Replace `claude` with any supported agent:
|
||||
|
||||
| Agent | Command |
|
||||
| --- | --- |
|
||||
| Claude Code | `unsloth start claude` |
|
||||
| OpenAI Codex | `unsloth start codex` |
|
||||
| Hermes Agent | `unsloth start hermes` |
|
||||
| OpenClaw | `unsloth start openclaw` |
|
||||
| OpenCode | `unsloth start opencode` |
|
||||
| Pi Coding Agent | `unsloth start pi` |
|
||||
|
||||
## 📥 Install
|
||||
Unsloth can be used in two ways: through **[Unsloth Studio](https://unsloth.ai/docs/new/studio/)**, the web UI, or through **Unsloth Core**, the code-based version. Each has different requirements.
|
||||
|
||||
|
|
@ -65,7 +95,8 @@ Unsloth Studio (Beta) works on **Windows, Linux, WSL** and **macOS**.
|
|||
* **CPU:** Supported for Chat and Data Recipes currently
|
||||
* **NVIDIA:** Training works on RTX 30/40/50, Blackwell, DGX Spark, Station and more
|
||||
* **macOS:** Training, MLX and GGUF inference are ALL supported.
|
||||
* **AMD:** Chat + Data works. Train with [Unsloth Core](#unsloth-core-code-based). Studio support is out soon.
|
||||
* **AMD:** Training, RL, chat and deployment work on Windows, WSL and Linux. [Read the AMD guide](https://unsloth.ai/docs/basics/amd).
|
||||
* **Vulkan:** GGUF inference is supported on [compatible GPUs, including Intel GPUs](https://github.com/unslothai/unsloth/pull/5819).
|
||||
* **Multi-GPU:** Available now, with a major upgrade on the way
|
||||
|
||||
#### macOS, Linux, WSL:
|
||||
|
|
@ -86,7 +117,7 @@ unsloth studio -p 8888
|
|||
```
|
||||
For LAN or cloud access, add `-H 0.0.0.0` (raw port only; add `--cloudflare` for a public URL). By default, Unsloth is accessible only locally.
|
||||
|
||||
To reach Studio over HTTPS, use `unsloth studio --secure`. Studio stays bound to localhost and is reached only through a free Cloudflare tunnel, which publishes it at a public `https://*.trycloudflare.com` URL (it fails closed if the tunnel can't start, so the raw port is never exposed). This makes Studio reachable from the internet, so anyone with the link and API key can use it and run code: keep your API key private (see Remote access below).
|
||||
To reach Unsloth over HTTPS, use `unsloth studio --secure`. Unsloth stays bound to localhost and is reached only through a free Cloudflare tunnel, which publishes it at a public `https://*.trycloudflare.com` URL (it fails closed if the tunnel can't start, so the raw port is never exposed). This makes Unsloth reachable from the internet, so anyone with the link and API key can use it and run code: keep your API key private (see Remote access below).
|
||||
|
||||
#### Docker
|
||||
Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Run:
|
||||
|
|
@ -122,7 +153,7 @@ You can use the same Docker image as Unsloth Studio.
|
|||
|
||||
#### AMD, Intel:
|
||||
For RTX 50x, B200, 6000 GPUs: `uv pip install unsloth --torch-backend=auto`. Read our guides for: [Blackwell](https://unsloth.ai/docs/blog/fine-tuning-llms-with-blackwell-rtx-50-series-and-unsloth) and [DGX Spark](https://unsloth.ai/docs/blog/fine-tuning-llms-with-nvidia-dgx-spark-and-unsloth). <br>
|
||||
To install Unsloth on **AMD** and **Intel** GPUs, follow our [AMD Guide](https://unsloth.ai/docs/get-started/install/amd) and [Intel Guide](https://unsloth.ai/docs/get-started/install/intel).
|
||||
To install Unsloth on **AMD** and **Intel** GPUs, follow our [AMD Guide](https://unsloth.ai/docs/basics/amd) and [Intel Guide](https://unsloth.ai/docs/get-started/install/intel).
|
||||
|
||||
## 📒 Free Notebooks
|
||||
|
||||
|
|
@ -148,13 +179,20 @@ Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Ad
|
|||
- See detailed documentation for Unsloth [here](https://unsloth.ai/docs)
|
||||
|
||||
## 🦥 Unsloth News
|
||||
- **Connections**: Connect any API provider (OpenAI, Anthropic) or server (vLLM, Ollama). [Guide](https://unsloth.ai/docs/integrations/connections)
|
||||
- **MTP**: Run Qwen3.6 MTP in Unsloth. MTP settings are autoset specific to your hardware. [Guide](https://unsloth.ai/docs/models/qwen3.6#mtp-guide)
|
||||
- **API inference endpoint**: Deploy and run local LLMs in Claude Code, Codex tools. [Guide](https://unsloth.ai/docs/basics/api)
|
||||
- **Qwen3.6**: Qwen3.6-35B-A3B can now be trained and run in Unsloth Studio. [Blog](https://unsloth.ai/docs/models/qwen3.6)
|
||||
- **Gemma 4**: Run and train Google’s new models directly in Unsloth. [Blog](https://unsloth.ai/docs/models/gemma-4)
|
||||
- **AMD training**: Train, run RL, chat and deploy on AMD GPUs across Windows, WSL and Linux. [Guide](https://unsloth.ai/docs/basics/amd)
|
||||
- **GGUF hardware controls**: Choose GPU/layer placement, offload MoE experts and use multi-GPU or Tensor Parallelism. [#6414](https://github.com/unslothai/unsloth/pull/6414)
|
||||
- **Local models for any agent**: Use `unsloth start` with Claude Code, Codex, Hermes, OpenCode, OpenClaw, Pi and more through Unsloth's OpenAI- and Anthropic-compatible APIs. [Guide](https://unsloth.ai/docs/basics/api)
|
||||
- **MCP control endpoint**: Let compatible clients manage models, training, recipes, checkpoints and exports. [#7191](https://github.com/unslothai/unsloth/pull/7191)
|
||||
- **Local inference reliability**: Resume long chats faster, recover stalled downloads and reuse existing GGUF files. [#7204](https://github.com/unslothai/unsloth/pull/7204) • [#6858](https://github.com/unslothai/unsloth/pull/6858) • [#7209](https://github.com/unslothai/unsloth/pull/7209)
|
||||
- **New models**: [Qwen-AgentWorld](https://huggingface.co/unsloth/Qwen-AgentWorld-35B-A3B-GGUF), [Ornith](https://huggingface.co/unsloth/models?search=ornith), [Kimi K2.7 Code](https://unsloth.ai/docs/models/kimi-k2.7-code) and [MiniMax M3](https://unsloth.ai/docs/models/minimax-m3)
|
||||
- **GLM-5.2**: Run Z.ai's 744B-parameter, 1M-context open model locally with Unsloth Dynamic GGUFs. [Guide](https://unsloth.ai/docs/models/glm-5.2)
|
||||
- **DeepSeek-V4**: Run DeepSeek-V4-Flash locally with corrected multi-turn and tool-calling behavior. [Guide](https://unsloth.ai/docs/models/deepseek-v4)
|
||||
- **DiffusionGemma**: Run and fine-tune Google's diffusion language model with 1.8x faster inference in Unsloth Studio. [Guide](https://unsloth.ai/docs/models/diffusiongemma)
|
||||
- **Qwen3.6**: Run and train Qwen3.6 with MTP for 1.4-2.2x faster inference and NVFP4 quants for supported GPUs. [Guide](https://unsloth.ai/docs/models/qwen3.6)
|
||||
- **Gemma 4**: Run and train Gemma 4 text, image and audio models with QAT, MTP, GGUF and MLX support. [Guide](https://unsloth.ai/docs/models/gemma-4)
|
||||
- **MCP servers**: Connect local models to files, apps, databases and external tools through Model Context Protocol. [Guide](https://unsloth.ai/docs/basics/mcp)
|
||||
- **Connections**: Mix local models with API providers (OpenAI, Anthropic) or servers (vLLM, Ollama) in the same interface. [Guide](https://unsloth.ai/docs/integrations/connections)
|
||||
- **Introducing Unsloth Studio**: our new web UI for running and training LLMs. [Blog](https://unsloth.ai/docs/new/studio)
|
||||
- **Qwen3.5** - 0.8B, 2B, 4B, 9B, 27B, 35-A3B, 112B-A10B are now supported. [Guide + notebooks](https://unsloth.ai/docs/models/qwen3.5/fine-tune)
|
||||
- Train **MoE LLMs 12x faster** with 35% less VRAM - DeepSeek, GLM, Qwen and gpt-oss. [Blog](https://unsloth.ai/docs/new/faster-moe)
|
||||
- **Embedding models**: Unsloth now supports ~1.8-3.3x faster embedding fine-tuning. [Blog](https://unsloth.ai/docs/new/embedding-finetuning) • [Notebooks](https://unsloth.ai/docs/get-started/unsloth-notebooks#embedding-models)
|
||||
- New **7x longer context RL** vs. all other setups, via our new batching algorithms. [Blog](https://unsloth.ai/docs/new/grpo-long-context)
|
||||
|
|
@ -208,7 +246,7 @@ unsloth studio -p 8888
|
|||
#### Remote access: `--secure` (HTTPS tunnel) vs raw port
|
||||
By default `unsloth studio` binds to `127.0.0.1` (this machine only). To reach it from another device, pick one of:
|
||||
|
||||
- `--secure` (recommended): serve **only** through a free Cloudflare HTTPS link. Studio stays bound to localhost and the tunnel provides the public URL; it fails closed (does not start) if the tunnel can't come up, so the raw port is never exposed.
|
||||
- `--secure` (recommended): serve **only** through a free Cloudflare HTTPS link. Unsloth stays bound to localhost and the tunnel provides the public URL; it fails closed (does not start) if the tunnel can't come up, so the raw port is never exposed.
|
||||
```bash
|
||||
unsloth studio --secure -p 8888
|
||||
```
|
||||
|
|
@ -218,7 +256,7 @@ unsloth studio -H 0.0.0.0 -p 8888
|
|||
```
|
||||
The Cloudflare tunnel is **off by default**: `-H 0.0.0.0` exposes the raw port only, not a public internet URL. Pair the wildcard bind with `--cloudflare` (`unsloth studio -H 0.0.0.0 --cloudflare`) to also publish a public `https://*.trycloudflare.com` link, or prefer `--secure` (above), which keeps the raw port private. `--cloudflare` has no effect on a loopback bind.
|
||||
|
||||
The first time Studio is published on a public URL (`--secure` or `--cloudflare`) with the auto-generated admin password still in place, it asks for a new admin password in the terminal (masked input with confirmation) before the public link goes up. Without an attached terminal it warns instead and keeps the bootstrap deadline: Studio shuts down after `UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT` (default 1 hour) unless the password is changed in the web UI.
|
||||
The first time Unsloth is published on a public URL (`--secure` or `--cloudflare`) with the auto-generated admin password still in place, it asks for a new admin password in the terminal (masked input with confirmation) before the public link goes up. Without an attached terminal it warns instead and keeps the bootstrap deadline: Unsloth shuts down after `UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT` (default 1 hour) unless the password is changed in the web UI.
|
||||
|
||||
For headless setups that cannot answer that prompt, set the initial admin password non-interactively with `--password` (only takes effect when no password is set yet; if one already exists it is a hard error, so rotate later with `unsloth studio reset-password`):
|
||||
|
||||
|
|
@ -230,7 +268,7 @@ printf '%s\n' 'your-strong-password' | unsloth studio --secure --password - #
|
|||
|
||||
A literal `--password VALUE` is visible in the process list and shell history, so prefer the `UNSLOTH_STUDIO_PASSWORD` env var or `--password -` (stdin) for automation. This applies to any launch (public or a headless `-H 0.0.0.0` bind), and the password is set in the parent before the server binds, so it never reaches a re-executed child process.
|
||||
|
||||
Server-side tools (web search, Python and terminal code execution) run as your user and are on by default. Anyone who can reach the server with the API key can run code on this machine, so keep your API key private and pass `--disable-tools` when exposing Studio.
|
||||
Server-side tools (web search, Python and terminal code execution) run as your user and are on by default. Anyone who can reach the server with the API key can run code on this machine, so keep your API key private and pass `--disable-tools` when exposing Unsloth.
|
||||
|
||||
#### Advanced launch options
|
||||
Installer options can be passed as environment variables. On macOS, Linux and WSL place the variable after the pipe so the shell passes it to `sh`; on Windows set it with `$env:` before piping to `iex`.
|
||||
|
|
@ -243,7 +281,7 @@ curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh
|
|||
$env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex
|
||||
```
|
||||
|
||||
Skip the post-install prompt that starts Studio (useful for automated installs):
|
||||
Skip the post-install prompt that starts Unsloth (useful for automated installs):
|
||||
```bash
|
||||
curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_SKIP_AUTOSTART=1 sh
|
||||
```
|
||||
|
|
@ -279,9 +317,9 @@ UNSLOTH_NPM_REGISTRY=https://artifactory.example.com/api/npm/npm/ ./install.sh -
|
|||
```powershell
|
||||
$env:UNSLOTH_NPM_REGISTRY='https://artifactory.example.com/api/npm/npm/'; .\install.ps1 --local
|
||||
```
|
||||
It is threaded as `--registry` into the Studio frontend `npm`/`bun` installs; the supply-chain locks (7-day `min-release-age`, exact version pins) stay in force.
|
||||
It is threaded as `--registry` into the Unsloth frontend `npm`/`bun` installs; the supply-chain locks (7-day `min-release-age`, exact version pins) stay in force.
|
||||
|
||||
Cap Studio's native CPU thread pools on high-core hosts: `UNSLOTH_CPU_THREADS=8 unsloth studio -p 8888`.
|
||||
Cap Unsloth's native CPU thread pools on high-core hosts: `UNSLOTH_CPU_THREADS=8 unsloth studio -p 8888`.
|
||||
|
||||
#### Uninstall
|
||||
The recommended way to fully remove Unsloth Studio is the matching uninstall script for your OS. It stops any running servers, removes the install dir, the launcher data dir, the desktop shortcut, and any platform-specific entries (macOS `.app` bundle + Launch Services on Mac; Start Menu, `HKCU\Software\Unsloth` registry key and user `PATH` entries on Windows):
|
||||
|
|
|
|||
8
build.sh
8
build.sh
|
|
@ -4,9 +4,9 @@
|
|||
|
||||
set -euo pipefail
|
||||
|
||||
# PyPI/Studio release publishing must use `./build.sh publish` (or an
|
||||
# equivalent stamp -> build -> verify-dist -> upload flow) so packaged Studio
|
||||
# artifacts include the display-only Studio release version.
|
||||
# PyPI/Unsloth release publishing must use `./build.sh publish` (or an
|
||||
# equivalent stamp -> build -> verify-dist -> upload flow) so packaged Unsloth
|
||||
# artifacts include the display-only Unsloth release version.
|
||||
|
||||
# 1. Build frontend (Vite outputs to dist/)
|
||||
cd studio/frontend
|
||||
|
|
@ -87,7 +87,7 @@ cd ../..
|
|||
# 2. Clean old artifacts
|
||||
rm -rf build dist *.egg-info
|
||||
|
||||
# 3. Stamp display-only Studio release metadata for packaged builds.
|
||||
# 3. Stamp display-only Unsloth release metadata for packaged builds.
|
||||
_STUDIO_BUILD_INFO="studio/backend/utils/_studio_release_build.py"
|
||||
_STUDIO_BUILD_INFO_BACKUP="$(mktemp)"
|
||||
cp "$_STUDIO_BUILD_INFO" "$_STUDIO_BUILD_INFO_BACKUP"
|
||||
|
|
|
|||
197
install.ps1
197
install.ps1
|
|
@ -53,7 +53,8 @@ function Install-UnslothStudio {
|
|||
param([string]$TorchIndexUrl)
|
||||
if ($SkipTorch) { return "none" }
|
||||
if ([string]::IsNullOrWhiteSpace($TorchIndexUrl)) { return "none" }
|
||||
$leaf = ($TorchIndexUrl.TrimEnd('/') -split '/')[-1].ToLowerInvariant()
|
||||
# Drop query/fragment first so a token-authenticated pin classifies by family.
|
||||
$leaf = (($TorchIndexUrl -split '[?#]', 2)[0].TrimEnd('/') -split '/')[-1].ToLowerInvariant()
|
||||
if (@("cpu", "cu118", "cu124", "cu126", "cu128", "cu130") -contains $leaf) { return $leaf }
|
||||
if ($leaf -match '^rocm[0-9]+\.[0-9]+$') { return $leaf }
|
||||
return "auto"
|
||||
|
|
@ -62,7 +63,8 @@ function Install-UnslothStudio {
|
|||
function Get-TauriGpuBranch {
|
||||
param([string]$TorchIndexFamily)
|
||||
if ($SkipTorch) { return "no_torch" }
|
||||
if ($TorchIndexFamily -like "cu*") { return "cuda" }
|
||||
# Require a digit after "cu" so /current or /custom isn't branded CUDA (parity ^cu[0-9]).
|
||||
if ($TorchIndexFamily -match '^cu[0-9]') { return "cuda" }
|
||||
if ($TorchIndexFamily -like "rocm*") { return "rocm" }
|
||||
if ($TorchIndexFamily -eq "cpu") { return "cpu" }
|
||||
return "unknown"
|
||||
|
|
@ -176,7 +178,7 @@ function Install-UnslothStudio {
|
|||
$envOverride = $env:STUDIO_HOME.Trim()
|
||||
}
|
||||
|
||||
# Custom Studio roots are not supported with --tauri (desktop app still
|
||||
# Custom Unsloth roots are not supported with --tauri (desktop app still
|
||||
# resolves %USERPROFILE%\.unsloth\studio). Pass through if override == legacy.
|
||||
if ($TauriMode -and $envOverride) {
|
||||
$_tauriOverride = $envOverride
|
||||
|
|
@ -467,22 +469,35 @@ function Install-UnslothStudio {
|
|||
}
|
||||
}
|
||||
|
||||
# Redact index-URL credentials (userinfo + ?query= + #fragment) from captured installer
|
||||
# output before printing on failure; uv/pip errors echo the failing --index-url verbatim.
|
||||
# Mirrors the other installers. Verbose mode streams uncaptured, so it isn't redacted.
|
||||
function Redact-InstallOutput {
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return $Text }
|
||||
$Text = $Text -replace '(https?://)[^/@\s`]+@', '$1<redacted>@'
|
||||
$Text = $Text -replace '([?&][^=\s&`]+)=[^&#\s`]+', '$1=<redacted>'
|
||||
# A #token=... fragment is as sensitive as a query; URL-anchored.
|
||||
return $Text -replace '(https?://[^\s`#]+)#[^\s`]+', '$1#<redacted>'
|
||||
}
|
||||
|
||||
# Run native commands quietly by default to match install.sh behavior.
|
||||
# Full command output is shown only when --verbose / UNSLOTH_VERBOSE=1.
|
||||
function Invoke-InstallCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][ScriptBlock]$Command
|
||||
)
|
||||
# Installer-pinned index installs (torch) must beat an inherited uv mirror
|
||||
# (#6898): when the command pins an index, clear every uv index env var so
|
||||
# it wins, then restore in finally. Other installs keep the user's mirror.
|
||||
# Installer-pinned index installs (torch) must beat an inherited uv mirror (#6898):
|
||||
# for --default-index, clear the uv index env vars (restore in finally) and set
|
||||
# UV_NO_CONFIG=1 so a uv.toml/pyproject index can't outrank the CLI pin (uv 0.10).
|
||||
$savedUvIndex = $null
|
||||
if ($Command.ToString() -match '--default-index') {
|
||||
$savedUvIndex = @{}
|
||||
foreach ($n in 'UV_DEFAULT_INDEX', 'UV_INDEX_URL', 'UV_INDEX', 'UV_EXTRA_INDEX_URL') {
|
||||
foreach ($n in 'UV_DEFAULT_INDEX', 'UV_INDEX_URL', 'UV_INDEX', 'UV_EXTRA_INDEX_URL', 'UV_TORCH_BACKEND', 'UV_FIND_LINKS', 'UV_CONFIG_FILE', 'UV_NO_CONFIG') {
|
||||
$savedUvIndex[$n] = [Environment]::GetEnvironmentVariable($n)
|
||||
Remove-Item "Env:$n" -ErrorAction SilentlyContinue
|
||||
}
|
||||
$env:UV_NO_CONFIG = '1'
|
||||
}
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
|
@ -493,17 +508,23 @@ function Install-UnslothStudio {
|
|||
# Merge stderr into stdout so progress/warning output stays visible
|
||||
# without flipping $? on successful native commands (PS 5.1 treats
|
||||
# stderr records as errors that set $? = $false even on exit code 0).
|
||||
& $Command 2>&1 | Out-Host
|
||||
# Redact per record: uv echoes index URLs (credentials and all) in
|
||||
# its errors, and verbose mode must not bypass the quiet path's
|
||||
# redaction. ForEach-Object/Out-Host leave $LASTEXITCODE untouched.
|
||||
& $Command 2>&1 | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host
|
||||
} else {
|
||||
$output = & $Command 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host $output -ForegroundColor Red
|
||||
Write-Host (Redact-InstallOutput $output) -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
return [int]$LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEap
|
||||
if ($savedUvIndex) { foreach ($n in $savedUvIndex.Keys) { if ($null -ne $savedUvIndex[$n]) { Set-Item "Env:$n" $savedUvIndex[$n] } } }
|
||||
if ($savedUvIndex) {
|
||||
Remove-Item "Env:UV_NO_CONFIG" -ErrorAction SilentlyContinue
|
||||
foreach ($n in $savedUvIndex.Keys) { if ($null -ne $savedUvIndex[$n]) { Set-Item "Env:$n" $savedUvIndex[$n] } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -756,7 +777,7 @@ function Find-FreeLaunchPort {
|
|||
return `$null
|
||||
}
|
||||
|
||||
# If Studio is already healthy on any expected port, just open it and exit.
|
||||
# If Unsloth is already healthy on any expected port, just open it and exit.
|
||||
`$existingPort = Find-HealthyStudioPort
|
||||
if (`$existingPort) {
|
||||
Start-Process "http://localhost:`$existingPort"
|
||||
|
|
@ -772,7 +793,7 @@ try {
|
|||
`$haveMutex = `$true
|
||||
}
|
||||
if (-not `$haveMutex) {
|
||||
# Another launcher is already running; wait for it to bring Studio up
|
||||
# Another launcher is already running; wait for it to bring Unsloth up
|
||||
`$deadline = (Get-Date).AddSeconds(`$timeoutSec)
|
||||
while ((Get-Date) -lt `$deadline) {
|
||||
`$port = Find-HealthyStudioPort
|
||||
|
|
@ -1438,7 +1459,7 @@ exit 0
|
|||
if (Test-Path -LiteralPath $VenvPython) {
|
||||
# why: matching guard to the .venv branch below -- in env-mode
|
||||
# $StudioHome is a user-chosen workspace, so refuse to nuke an
|
||||
# existing $StudioHome\unsloth_studio that lacks Studio sentinels.
|
||||
# existing $StudioHome\unsloth_studio that lacks Unsloth sentinels.
|
||||
# -PathType Leaf rejects a directory at the sentinel path. Accept the
|
||||
# in-VENV ownership marker so partial-install retries are not blocked.
|
||||
if (
|
||||
|
|
@ -1449,7 +1470,7 @@ exit 0
|
|||
) {
|
||||
Write-Host "[ERROR] $VenvDir already exists but does not look like an Unsloth Studio install." -ForegroundColor Red
|
||||
Write-Host " Move it aside or choose an empty UNSLOTH_STUDIO_HOME." -ForegroundColor Yellow
|
||||
throw "Refusing to delete non-Studio venv at $VenvDir"
|
||||
throw "Refusing to delete non-Unsloth venv at $VenvDir"
|
||||
}
|
||||
# New layout already exists -- replace only after preserving rollback copy.
|
||||
substep "preserving existing environment for rollback..."
|
||||
|
|
@ -1468,7 +1489,7 @@ exit 0
|
|||
# workspace root (e.g. user's existing project Python venv).
|
||||
$OldVenv = Join-Path $StudioHome ".venv"
|
||||
$OldPy = Join-Path $OldVenv "Scripts\python.exe"
|
||||
substep "found legacy Studio environment, validating..."
|
||||
substep "found legacy Unsloth environment, validating..."
|
||||
$prevEAP2 = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
|
|
@ -1498,7 +1519,7 @@ exit 0
|
|||
# Skip in env-mode so we don't relocate the default-install venv into
|
||||
# the workspace root.
|
||||
$CwdVenv = Join-Path $env:USERPROFILE "unsloth_studio"
|
||||
substep "found CWD-relative Studio environment, migrating to $VenvDir..."
|
||||
substep "found CWD-relative Unsloth environment, migrating to $VenvDir..."
|
||||
Move-Item -LiteralPath $CwdVenv -Destination $VenvDir -Force
|
||||
substep "moved ~/unsloth_studio -> ~/.unsloth/studio/unsloth_studio"
|
||||
$_Migrated = $true
|
||||
|
|
@ -1517,7 +1538,7 @@ exit 0
|
|||
substep "$VenvDir"
|
||||
}
|
||||
|
||||
# Mark the freshly-created venv as Studio-owned so a partial install can be
|
||||
# Mark the freshly-created venv as Unsloth-owned so a partial install can be
|
||||
# repaired by re-running install.ps1; the env-mode deletion guard above
|
||||
# accepts this marker as the primary sentinel.
|
||||
if (Test-Path -LiteralPath $VenvDir -PathType Container) {
|
||||
|
|
@ -1526,7 +1547,7 @@ exit 0
|
|||
|
||||
# ── Helper: run amd-smi without triggering a UAC elevation prompt ──
|
||||
# amd-smi on Windows auto-elevates to read GPU/APU memory, surfacing a confusing
|
||||
# DiskPart UAC prompt mid-install (Studio backend amd.py hits the same).
|
||||
# DiskPart UAC prompt mid-install (Unsloth backend amd.py hits the same).
|
||||
# __COMPAT_LAYER=RunAsInvoker forces it (and helpers it spawns) to run
|
||||
# un-elevated; on failure the WMI name -> gfx fallback still resolves the arch.
|
||||
function Invoke-AmdSmiNoElevate {
|
||||
|
|
@ -1653,7 +1674,7 @@ exit 0
|
|||
function Test-HipinfoIsVenvInternal {
|
||||
param([AllowNull()][string]$HipinfoPath)
|
||||
if ([string]::IsNullOrWhiteSpace($HipinfoPath)) { return $false }
|
||||
# Also derive the venv from the setup python + default Studio home, so
|
||||
# Also derive the venv from the setup python + default Unsloth home, so
|
||||
# the venv hipInfo is caught when VenvDir/VIRTUAL_ENV are unset.
|
||||
$venvRoots = @()
|
||||
if ($env:VIRTUAL_ENV) { $venvRoots += $env:VIRTUAL_ENV }
|
||||
|
|
@ -1663,7 +1684,7 @@ exit 0
|
|||
try { $venvRoots += (Split-Path -Parent (Split-Path -Parent $env:UNSLOTH_SETUP_PYTHON)) } catch {}
|
||||
}
|
||||
if ($env:USERPROFILE) { $venvRoots += (Join-Path $env:USERPROFILE ".unsloth\studio\unsloth_studio") }
|
||||
# A custom Studio home (UNSLOTH_STUDIO_HOME / STUDIO_HOME alias) moves the
|
||||
# A custom Unsloth home (UNSLOTH_STUDIO_HOME / STUDIO_HOME alias) moves the
|
||||
# venv off the default path; seed it too or its hipInfo escapes the filter.
|
||||
$studioHomeEnv = if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) { $env:UNSLOTH_STUDIO_HOME.Trim() } elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) { $env:STUDIO_HOME.Trim() } else { $null }
|
||||
if ($studioHomeEnv) {
|
||||
|
|
@ -1942,7 +1963,7 @@ exit 0
|
|||
substep " Ensure the ROCm compute driver is installed alongside the display driver:" "Yellow"
|
||||
substep " https://rocm.docs.amd.com/en/latest/deploy/windows/index.html" "Yellow"
|
||||
} elseif ($ROCmGfxArch) {
|
||||
# Known arch: Studio setup installs AMD's bundled-runtime ROCm PyTorch wheels
|
||||
# Known arch: Unsloth setup installs AMD's bundled-runtime ROCm PyTorch wheels
|
||||
# (repo.amd.com), which ship their own runtime -- HIP SDK optional.
|
||||
step "gpu" "AMD ROCm ($ROCmGfxArch)" "Cyan"
|
||||
substep "Detected: $ROCmGpuLabel" "Cyan"
|
||||
|
|
@ -1960,10 +1981,31 @@ exit 0
|
|||
# On an AMD GPU (no NVIDIA), surface the optional WSL-ROCm driver hint.
|
||||
if (-not $HasNvidiaSmi -and ($ROCmGfxArch -or $ROCmGpuLabel)) { Show-AmdWslDriverHint }
|
||||
|
||||
# Trim trailing slashes from the URL PATH only, preserving ?query / #fragment: a whole-URL
|
||||
# TrimEnd corrupts a token ending in "/", a single strip leaves .../cu128// empty. Shared.
|
||||
function Trim-IndexPathSlashes {
|
||||
param([string]$Url)
|
||||
$value = $Url.Trim()
|
||||
$idx = $value.IndexOfAny([char[]]@('?', '#'))
|
||||
if ($idx -lt 0) {
|
||||
return $value.TrimEnd('/')
|
||||
}
|
||||
return $value.Substring(0, $idx).TrimEnd('/') + $value.Substring($idx)
|
||||
}
|
||||
|
||||
# ── Choose the correct PyTorch index URL based on driver CUDA version ──
|
||||
# Mirrors Get-PytorchCudaTag in setup.ps1.
|
||||
function Get-TorchIndexUrl {
|
||||
$baseUrl = if ($env:UNSLOTH_PYTORCH_MIRROR) { $env:UNSLOTH_PYTORCH_MIRROR.TrimEnd('/') } else { "https://download.pytorch.org/whl" }
|
||||
# Explicit pin -- skip ALL GPU probing (headless / CI / cross-install).
|
||||
# UNSLOTH_TORCH_INDEX_URL wins (full URL, verbatim); _FAMILY is the leaf appended
|
||||
# to the mirror base. Matches install.sh / install_python_stack.py.
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_URL)) {
|
||||
return (Trim-IndexPathSlashes $env:UNSLOTH_TORCH_INDEX_URL)
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_FAMILY)) {
|
||||
return "$baseUrl/$($env:UNSLOTH_TORCH_INDEX_FAMILY.Trim().Trim('/'))"
|
||||
}
|
||||
if (-not $NvidiaSmiExe) { return "$baseUrl/cpu" }
|
||||
try {
|
||||
$output = Invoke-NvidiaSmiBounded $NvidiaSmiExe
|
||||
|
|
@ -1984,6 +2026,25 @@ exit 0
|
|||
return "$baseUrl/cu126"
|
||||
}
|
||||
|
||||
# Strip userinfo AND query/fragment so an authenticated pin never leaks. Shared with
|
||||
# _strip_index_url_credentials (install.sh / py / setup.ps1).
|
||||
function Remove-IndexUrlCredentials {
|
||||
param([string]$Url)
|
||||
$sep = $Url.IndexOf('://')
|
||||
if ($sep -lt 0) { return $Url }
|
||||
$scheme = $Url.Substring(0, $sep)
|
||||
$rest = $Url.Substring($sep + 3)
|
||||
# Drop query / fragment (may hold auth tokens).
|
||||
$q = $rest.IndexOfAny([char[]]('?', '#'))
|
||||
if ($q -ge 0) { $rest = $rest.Substring(0, $q) }
|
||||
$slash = $rest.IndexOf('/')
|
||||
$authority = if ($slash -ge 0) { $rest.Substring(0, $slash) } else { $rest }
|
||||
$at = $authority.LastIndexOf('@')
|
||||
$host_ = if ($at -ge 0) { $authority.Substring($at + 1) } else { $authority }
|
||||
if ($slash -ge 0) { return "${scheme}://${host_}$($rest.Substring($slash))" }
|
||||
return "${scheme}://${host_}"
|
||||
}
|
||||
|
||||
# ── Torch flavor helpers (to repair a stale CPU / wrong-CUDA wheel) ──
|
||||
# torch.__version__ -> flavor tag (cuXXX / rocm / cpu); untagged wheel = cpu,
|
||||
# matching setup.ps1's stale-venv parse.
|
||||
|
|
@ -2002,11 +2063,13 @@ exit 0
|
|||
param([string]$TorchIndexUrl, [string]$ROCmIndexUrl)
|
||||
if (-not [string]::IsNullOrWhiteSpace($ROCmIndexUrl)) { return 'rocm' }
|
||||
if ([string]::IsNullOrWhiteSpace($TorchIndexUrl)) { return $null }
|
||||
$leaf = ($TorchIndexUrl.TrimEnd('/') -split '/')[-1].ToLowerInvariant()
|
||||
# Drop query/fragment first so .../cu128?token=x classifies as cu128 (else it reinstalls every run).
|
||||
$leaf = (($TorchIndexUrl -split '[?#]', 2)[0].TrimEnd('/') -split '/')[-1].ToLowerInvariant()
|
||||
if ($leaf -match '^cu\d+$') { return $leaf }
|
||||
if ($leaf -eq 'cpu') { return 'cpu' }
|
||||
if ($leaf -match '^rocm') { return 'rocm' }
|
||||
if ($leaf -match '^gfx') { return 'rocm' }
|
||||
# gfx must be followed by a digit (an architecture leaf); gfx-private is custom.
|
||||
if ($leaf -match '^gfx[0-9]') { return 'rocm' }
|
||||
return $null
|
||||
}
|
||||
|
||||
|
|
@ -2041,6 +2104,10 @@ exit 0
|
|||
} catch { return $null }
|
||||
}
|
||||
|
||||
# An explicit pin is authoritative: the AMD ROCm reroute below must not rewrite it
|
||||
# (e.g. a deliberate cpu pin on an AMD host).
|
||||
$TorchIndexPinned = (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_URL)) -or `
|
||||
(-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_FAMILY))
|
||||
$TorchIndexUrl = Get-TorchIndexUrl
|
||||
|
||||
# ── GPU arch → newest compatible Windows ROCm wheel release ──
|
||||
|
|
@ -2052,7 +2119,9 @@ exit 0
|
|||
# Override with UNSLOTH_ROCM_WINDOWS_MIRROR for air-gapped / mirror installs.
|
||||
$ROCmIndexUrl = $null
|
||||
$ROCmTorchFloor = $null
|
||||
if (($HasROCm -or $ROCmGfxArch) -and $TorchIndexUrl -like "*/cpu" -and -not $SkipTorch) {
|
||||
$PinnedRocmVisionSpec = $null
|
||||
$PinnedRocmAudioSpec = $null
|
||||
if (-not $TorchIndexPinned -and ($HasROCm -or $ROCmGfxArch) -and $TorchIndexUrl -like "*/cpu" -and -not $SkipTorch) {
|
||||
$amdIndexBase = if ($env:UNSLOTH_ROCM_WINDOWS_MIRROR) { $env:UNSLOTH_ROCM_WINDOWS_MIRROR.TrimEnd('/') } else { "https://repo.amd.com/rocm/whl" }
|
||||
$archFamilyMap = @{
|
||||
"gfx1201" = "gfx120X-all"; "gfx1200" = "gfx120X-all" # RDNA 4
|
||||
|
|
@ -2102,6 +2171,32 @@ exit 0
|
|||
}
|
||||
}
|
||||
|
||||
# A gfx*/rocm pin skips the auto-reroute above, but the generic CPU/CUDA install below
|
||||
# would use torch>=2.4,<2.11 and pull a known-bad wheel on the gfx115x/gfx120x/rocm>=7.2
|
||||
# indexes (the _grouped_mm bug). Route a pinned ROCm index through the ROCm path.
|
||||
if ($TorchIndexPinned -and -not $ROCmIndexUrl -and -not $SkipTorch) {
|
||||
$_pinLeaf = (($TorchIndexUrl -split '[?#]', 2)[0].TrimEnd('/') -split '/')[-1].ToLower()
|
||||
$_pinRocm211 = $false
|
||||
# Anchor ($) so a suffixed custom leaf (rocm7.2-private) falls through to verbatim.
|
||||
if ($_pinLeaf -match '^rocm(\d+)\.(\d+)$') {
|
||||
# Only KNOWN-2.11 rocm (rocm7.2) gets the floor. Matches Test-RocmKnown211Version.
|
||||
$_pinRocm211 = ([int]$Matches[1] -eq 7 -and [int]$Matches[2] -eq 2)
|
||||
}
|
||||
# Only the 2.11-allowlist gfx arches need the floor; others publish <2.11 and stay bare.
|
||||
$_pinGfx211 = @('gfx120x-all', 'gfx1151', 'gfx1150') -contains $_pinLeaf
|
||||
if ($_pinGfx211 -or $_pinRocm211) {
|
||||
$ROCmIndexUrl = $TorchIndexUrl
|
||||
$ROCmTorchFloor = "torch>=2.11.0,<2.12.0"
|
||||
$PinnedRocmVisionSpec = "torchvision>=0.26.0,<0.27.0"
|
||||
$PinnedRocmAudioSpec = "torchaudio>=2.11.0,<2.12.0"
|
||||
substep "pinned ROCm index ($_pinLeaf) -- enforcing $ROCmTorchFloor" "Cyan"
|
||||
} elseif ($_pinLeaf -match '^gfx[0-9]' -or $_pinLeaf -match '^rocm[0-9]+(\.[0-9]+)?$') {
|
||||
# Other gfx / older rocm (<=7.1) ship torch <2.11; route via the ROCm path with
|
||||
# bare specs. Only EXACT rocm<digits>/gfx* are families; a suffixed leaf is verbatim.
|
||||
$ROCmIndexUrl = $TorchIndexUrl
|
||||
}
|
||||
}
|
||||
|
||||
if ($ROCmIndexUrl) {
|
||||
$TorchIndexFamily = "rocm"
|
||||
} else {
|
||||
|
|
@ -2164,14 +2259,14 @@ exit 0
|
|||
}
|
||||
|
||||
if ($_Migrated) {
|
||||
# Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state
|
||||
# in the new venv location, while preserving existing torch/CUDA
|
||||
# Migrated env: force-reinstall unsloth+unsloth-zoo for a clean state, preserving
|
||||
# existing torch/CUDA unless the flavor repair below re-lands it.
|
||||
Write-TauriLog "STEP" "Installing unsloth"
|
||||
substep "upgrading unsloth in migrated environment..."
|
||||
if ($SkipTorch) {
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" }
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3" }
|
||||
if ($baseInstallExit -eq 0) {
|
||||
# Resolve pydantic WITH deps so pip pins pydantic-core
|
||||
# to the matching version (no-torch-runtime.txt below
|
||||
|
|
@ -2185,7 +2280,7 @@ exit 0
|
|||
}
|
||||
}
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" }
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3" }
|
||||
}
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
|
|
@ -2210,22 +2305,24 @@ exit 0
|
|||
substep "skipping PyTorch (--no-torch flag set)." "Yellow"
|
||||
} elseif ($ROCmIndexUrl) {
|
||||
Write-TauriLog "STEP" "Installing PyTorch (AMD ROCm Windows)"
|
||||
substep "installing PyTorch from $ROCmIndexUrl..."
|
||||
substep "installing PyTorch from $(Remove-IndexUrlCredentials $ROCmIndexUrl)..."
|
||||
$torchSpec = if ($ROCmTorchFloor) { $ROCmTorchFloor } else { "torch" }
|
||||
# Pin the companions to match $torchSpec; bare names can resolve an
|
||||
# ABI-incompatible torchvision/torchaudio on AMD's per-arch index.
|
||||
$visionSpec = if ($ROCmGfxArch -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
|
||||
$audioSpec = if ($ROCmGfxArch -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" }
|
||||
$visionSpec = if ($PinnedRocmVisionSpec) { $PinnedRocmVisionSpec } elseif ($ROCmGfxArch -and $torchvisionFloorMap -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
|
||||
$audioSpec = if ($PinnedRocmAudioSpec) { $PinnedRocmAudioSpec } elseif ($ROCmGfxArch -and $torchaudioFloorMap -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" }
|
||||
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (AMD ROCm)" { uv pip install --python $VenvPython --force-reinstall --default-index $ROCmIndexUrl $torchSpec $visionSpec $audioSpec }
|
||||
if ($torchInstallExit -ne 0) {
|
||||
# Transient AMD-index failure: fall back to a CPU base so the install
|
||||
# still completes; Studio setup retries ROCm afterwards.
|
||||
substep "ROCm PyTorch install failed (exit $torchInstallExit); using a CPU base, Studio setup retries ROCm." "Yellow"
|
||||
# Transient AMD-index failure: fall back to a CPU base (Unsloth setup retries
|
||||
# ROCm). Use an explicit CPU index -- for a pinned ROCm index $TorchIndexUrl IS
|
||||
# the ROCm mirror, so reusing it would just retry it.
|
||||
$CpuFallbackIndexUrl = if ($env:UNSLOTH_PYTORCH_MIRROR) { "$($env:UNSLOTH_PYTORCH_MIRROR.TrimEnd('/'))/cpu" } else { "https://download.pytorch.org/whl/cpu" }
|
||||
substep "ROCm PyTorch install failed (exit $torchInstallExit); using a CPU base, Unsloth setup retries ROCm." "Yellow"
|
||||
# --force-reinstall: a failed ROCm install can leave an unpinned ROCm
|
||||
# torch (e.g. 2.10.0+rocm on gfx110X/gfx90a) that still satisfies the CPU
|
||||
# torch>= range, so without it uv would keep the ROCm build and only swap
|
||||
# the companions -- a mismatched venv the flavor-repair block won't fix.
|
||||
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (CPU fallback)" { uv pip install --python $VenvPython --force-reinstall "torch>=2.4,<2.11.0" torchvision torchaudio --default-index $TorchIndexUrl }
|
||||
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (CPU fallback)" { uv pip install --python $VenvPython --force-reinstall "torch>=2.4,<2.11.0" "torchvision>=0.19,<0.26.0" "torchaudio>=2.4,<2.11.0" --default-index $CpuFallbackIndexUrl }
|
||||
if ($torchInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install PyTorch (ROCm and CPU base both failed, exit code $torchInstallExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
|
||||
|
|
@ -2238,8 +2335,14 @@ exit 0
|
|||
}
|
||||
} else {
|
||||
Write-TauriLog "STEP" "Installing PyTorch"
|
||||
substep "installing PyTorch ($TorchIndexUrl)..."
|
||||
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --default-index $TorchIndexUrl }
|
||||
substep "installing PyTorch ($(Remove-IndexUrlCredentials $TorchIndexUrl))..."
|
||||
# Bound the companions to the capped torch on EVERY index, cu<digits>
|
||||
# families included: torchaudio 2.11 dropped its exact torch pin from
|
||||
# the wheel metadata, so a bare companion next to torch<2.11 can
|
||||
# resolve a mismatched 2.11.0 build. Mirrors install.sh.
|
||||
$_pinVisionSpec = "torchvision>=0.19,<0.26.0"
|
||||
$_pinAudioSpec = "torchaudio>=2.4,<2.11.0"
|
||||
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" $_pinVisionSpec $_pinAudioSpec --default-index $TorchIndexUrl }
|
||||
if ($torchInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install PyTorch (exit code $torchInstallExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
|
||||
|
|
@ -2251,7 +2354,7 @@ exit 0
|
|||
if ($SkipTorch) {
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" }
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3" }
|
||||
if ($baseInstallExit -eq 0) {
|
||||
# Same pydantic-with-deps trick as the migrated branch.
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install pydantic" { uv pip install --python $VenvPython pydantic }
|
||||
|
|
@ -2263,7 +2366,7 @@ exit 0
|
|||
}
|
||||
}
|
||||
} elseif ($StudioLocalInstall) {
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" }
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3" }
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth" { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
|
||||
}
|
||||
|
|
@ -2291,7 +2394,7 @@ exit 0
|
|||
Write-TauriLog "STEP" "Installing unsloth"
|
||||
substep "installing unsloth (this may take a few minutes)..."
|
||||
if ($StudioLocalInstall) {
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.7.2" "unsloth>=2026.7.2" --torch-backend=auto }
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.7.3" "unsloth>=2026.7.3" --torch-backend=auto }
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
||||
|
|
@ -2335,8 +2438,8 @@ exit 0
|
|||
$rocmSpec = if ($ROCmTorchFloor) { $ROCmTorchFloor } else { "torch" }
|
||||
# Pin companions like the fresh ROCm path (bare names can pull an
|
||||
# ABI-incompatible torchvision/torchaudio from the per-arch index).
|
||||
$visionSpec = if ($ROCmGfxArch -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
|
||||
$audioSpec = if ($ROCmGfxArch -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" }
|
||||
$visionSpec = if ($PinnedRocmVisionSpec) { $PinnedRocmVisionSpec } elseif ($ROCmGfxArch -and $torchvisionFloorMap -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
|
||||
$audioSpec = if ($PinnedRocmAudioSpec) { $PinnedRocmAudioSpec } elseif ($ROCmGfxArch -and $torchaudioFloorMap -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" }
|
||||
substep "PyTorch flavor mismatch (installed $installedTorchTag, need ROCm) -- reinstalling correct build..." "Yellow"
|
||||
$torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython --force-reinstall --default-index $ROCmIndexUrl $rocmSpec $visionSpec $audioSpec }
|
||||
if ($torchFixExit -ne 0) {
|
||||
|
|
@ -2347,7 +2450,7 @@ exit 0
|
|||
} elseif ($expectedTorchTag -ne 'rocm') {
|
||||
# CUDA: stale +cpu (or wrong cuXXX) against a CUDA index -> reinstall triplet.
|
||||
substep "PyTorch flavor mismatch (installed $installedTorchTag, need $expectedTorchTag) -- reinstalling correct build..." "Yellow"
|
||||
$torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --default-index $TorchIndexUrl --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio }
|
||||
$torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" "torchvision>=0.19,<0.26.0" "torchaudio>=2.4,<2.11.0" --default-index $TorchIndexUrl --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio }
|
||||
if ($torchFixExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to reinstall PyTorch with the correct CUDA build (exit code $torchFixExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to reinstall PyTorch ($expectedTorchTag) (exit code $torchFixExit)" $torchFixExit)
|
||||
|
|
@ -2422,7 +2525,7 @@ exit 0
|
|||
Write-TauriLog "ERROR" "unsloth CLI was not installed correctly"
|
||||
Write-Host "[ERROR] unsloth CLI was not installed correctly." -ForegroundColor Red
|
||||
Write-Host " Expected: $UnslothExe" -ForegroundColor Yellow
|
||||
Write-Host " This usually means an older unsloth version was installed that does not include the Studio CLI." -ForegroundColor Yellow
|
||||
Write-Host " This usually means an older unsloth version was installed that does not include the Unsloth CLI." -ForegroundColor Yellow
|
||||
Write-Host " Try re-running the installer or see: https://github.com/unslothai/unsloth?tab=readme-ov-file#-quickstart" -ForegroundColor Yellow
|
||||
return (Exit-InstallFailure "unsloth CLI was not installed correctly")
|
||||
}
|
||||
|
|
@ -2533,7 +2636,7 @@ exit 0
|
|||
Write-Host " Move or remove it manually, then re-run the installer." -ForegroundColor Yellow
|
||||
throw "Cannot create unsloth launcher: $ShimExe is a directory."
|
||||
}
|
||||
# try/catch: if unsloth.exe is locked (Studio running), keep the old shim.
|
||||
# try/catch: if unsloth.exe is locked (Unsloth running), keep the old shim.
|
||||
$shimUpdated = $false
|
||||
try {
|
||||
if (Test-Path -LiteralPath $ShimExe) { Remove-Item -LiteralPath $ShimExe -Force -ErrorAction Stop }
|
||||
|
|
@ -2551,7 +2654,7 @@ exit 0
|
|||
if (Test-Path -LiteralPath $ShimExe) {
|
||||
Write-Host "[WARN] Could not refresh unsloth launcher at $ShimExe." -ForegroundColor Yellow
|
||||
Write-Host " This usually means a running 'unsloth studio' process still holds the file open." -ForegroundColor Yellow
|
||||
Write-Host " Close Studio and re-run the installer to pick up the latest launcher." -ForegroundColor Yellow
|
||||
Write-Host " Close Unsloth and re-run the installer to pick up the latest launcher." -ForegroundColor Yellow
|
||||
Write-Host " Continuing with the existing launcher." -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "[WARN] Could not create unsloth launcher at $ShimExe" -ForegroundColor Yellow
|
||||
|
|
@ -2616,7 +2719,7 @@ exit 0
|
|||
# Diagnostic only; never block install on a probe failure.
|
||||
}
|
||||
|
||||
# In interactive terminals, ask the user before starting Studio unless the
|
||||
# In interactive terminals, ask the user before starting Unsloth unless the
|
||||
# caller explicitly disabled the post-install prompt.
|
||||
# In non-interactive environments (CI, Docker) just print instructions.
|
||||
$IsInteractive = (-not $SkipAutostart) -and [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected)
|
||||
|
|
|
|||
607
install.sh
607
install.sh
|
|
@ -97,7 +97,7 @@ if [ "$_VERBOSE" = true ]; then
|
|||
export UNSLOTH_VERBOSE=1
|
||||
fi
|
||||
|
||||
# Custom Studio roots are not supported with --tauri (desktop app still
|
||||
# Custom Unsloth roots are not supported with --tauri (desktop app still
|
||||
# resolves ~/.unsloth/studio). Pass through if the override == legacy default.
|
||||
if [ "$TAURI_MODE" = true ]; then
|
||||
_tauri_override_var=""
|
||||
|
|
@ -159,18 +159,58 @@ run_maybe_quiet() {
|
|||
fi
|
||||
}
|
||||
|
||||
# Trim trailing slashes from the URL PATH only, preserving ?query / #fragment: a whole-URL
|
||||
# strip corrupts a token ending in "/", a single strip leaves .../cu128// empty. Shared.
|
||||
_trim_index_path_slashes() {
|
||||
_tips_v="$1"
|
||||
case "$_tips_v" in
|
||||
*[?#]*)
|
||||
_tips_head="${_tips_v%%[?#]*}"
|
||||
_tips_tail="${_tips_v#"$_tips_head"}"
|
||||
;;
|
||||
*)
|
||||
_tips_head="$_tips_v"
|
||||
_tips_tail=""
|
||||
;;
|
||||
esac
|
||||
while [ -n "$_tips_head" ] && [ "${_tips_head%/}" != "$_tips_head" ]; do
|
||||
_tips_head="${_tips_head%/}"
|
||||
done
|
||||
printf '%s%s' "$_tips_head" "$_tips_tail"
|
||||
}
|
||||
|
||||
# Redact index-URL credentials (userinfo + ?query= + #fragment) from captured installer
|
||||
# output before printing on failure; uv/pip errors echo the failing --index-url verbatim.
|
||||
# Mirrors the other installers. Verbose mode streams uncaptured, so it isn't redacted.
|
||||
_redact_install_output() {
|
||||
sed -E \
|
||||
-e 's#(https?://)[^/@[:space:]`]+@#\1<redacted>@#g' \
|
||||
-e 's#([?&][^=[:space:]&`]+)=[^&#[:space:]`]+#\1=<redacted>#g' \
|
||||
-e 's|(https?://[^[:space:]`#]+)#[^[:space:]`]+|\1#<redacted>|g' \
|
||||
"$@"
|
||||
}
|
||||
|
||||
run_install_cmd() {
|
||||
_label="$1"
|
||||
shift
|
||||
# Installer-pinned index installs (torch) must beat an inherited uv mirror
|
||||
# (#6898): when we pass --default-index, neutralize every uv index env var so
|
||||
# the pinned index wins. Other installs keep the user's mirror.
|
||||
# Installer-pinned index installs (torch) must beat an inherited uv mirror (#6898):
|
||||
# for --default-index, neutralize the uv index/backend/config vars (UV_TORCH_BACKEND
|
||||
# redirects torch; UV_NO_CONFIG=1 + dropping UV_CONFIG_FILE stops a uv.toml/pyproject
|
||||
# index outranking the CLI pin, uv 0.10).
|
||||
case " $* " in
|
||||
*" --default-index "*) set -- env -u UV_DEFAULT_INDEX -u UV_INDEX_URL -u UV_INDEX -u UV_EXTRA_INDEX_URL "$@" ;;
|
||||
*" --default-index "*) set -- env -u UV_DEFAULT_INDEX -u UV_INDEX_URL -u UV_INDEX -u UV_EXTRA_INDEX_URL -u UV_TORCH_BACKEND -u UV_FIND_LINKS -u UV_CONFIG_FILE UV_NO_CONFIG=1 "$@" ;;
|
||||
esac
|
||||
if _is_verbose; then
|
||||
"$@" && return 0
|
||||
_rc=$?
|
||||
# Stream through the redactor: uv echoes index URLs (credentials and
|
||||
# all) in its errors, and verbose mode previously bypassed the
|
||||
# redaction the quiet path applies. The rc file preserves the
|
||||
# command's exit code across the pipe without relying on pipefail
|
||||
# (this script runs under plain sh).
|
||||
_rcf=$(mktemp)
|
||||
{ "$@" 2>&1; printf '%s' "$?" > "$_rcf"; } | _redact_install_output
|
||||
_rc=$(cat "$_rcf" 2>/dev/null || echo 1)
|
||||
rm -f "$_rcf"
|
||||
[ "${_rc:-1}" -eq 0 ] 2>/dev/null && return 0
|
||||
step "error" "$_label failed (exit code $_rc)" "$C_ERR" >&2
|
||||
return "$_rc"
|
||||
fi
|
||||
|
|
@ -178,7 +218,7 @@ run_install_cmd() {
|
|||
"$@" >"$_log" 2>&1 && { rm -f "$_log"; return 0; }
|
||||
_rc=$?
|
||||
step "error" "$_label failed (exit code $_rc)" "$C_ERR" >&2
|
||||
cat "$_log" >&2
|
||||
_redact_install_output "$_log" >&2
|
||||
rm -f "$_log"
|
||||
return $_rc
|
||||
}
|
||||
|
|
@ -257,7 +297,7 @@ _install_bnb_rocm() {
|
|||
fi
|
||||
_bnb_rc=$?
|
||||
if _is_verbose; then
|
||||
cat "$_bnb_log" >&2
|
||||
_redact_install_output "$_bnb_log" >&2
|
||||
fi
|
||||
rm -f "$_bnb_log"
|
||||
step "warning" "$_label (pre-release) failed (exit code $_bnb_rc)" "$C_WARN" >&2
|
||||
|
|
@ -310,6 +350,11 @@ _tauri_torch_index_family() {
|
|||
return
|
||||
fi
|
||||
_diag_url="${1:-}"
|
||||
# Strip query/fragment AND a trailing slash before classifying (like _torch_index_url_leaf):
|
||||
# a token isn't echoed into [TAURI:DIAG], and .../cu128/?token=x still classifies as cu128.
|
||||
_diag_url="${_diag_url%%\?*}"
|
||||
_diag_url="${_diag_url%%#*}"
|
||||
_diag_url="${_diag_url%/}"
|
||||
case "$_diag_url" in
|
||||
*/cu118) echo "cu118" ;;
|
||||
*/cu124) echo "cu124" ;;
|
||||
|
|
@ -343,7 +388,8 @@ _tauri_gpu_branch() {
|
|||
return
|
||||
fi
|
||||
case "$_diag_family" in
|
||||
cu*) echo "cuda" ;;
|
||||
# Require a digit after cu so /current or /custom isn't branded CUDA (parity ^cu[0-9]).
|
||||
cu[0-9]*) echo "cuda" ;;
|
||||
rocm*)
|
||||
if [ "$_diag_radeon" = true ]; then
|
||||
echo "rocm_radeon"
|
||||
|
|
@ -472,11 +518,13 @@ _on_install_exit() {
|
|||
_restore_studio_venv_replacement
|
||||
fi
|
||||
[ -n "${_UV_OVERRIDE_TMPDIR:-}" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true
|
||||
[ -n "${_UNSLOTH_TORCH_OVERRIDES:-}" ] && rm -f "$_UNSLOTH_TORCH_OVERRIDES" 2>/dev/null || true
|
||||
exit "$_status"
|
||||
}
|
||||
# Empty so an inherited value can never reach the trap's rm; only a temp dir
|
||||
# this script creates below (Apple Silicon, spaced path) is ever removed.
|
||||
# Empty so an inherited value never reaches the trap's rm; only temp paths this
|
||||
# script creates below (spaced-path dir, torch-trio overrides) are removed.
|
||||
_UV_OVERRIDE_TMPDIR=""
|
||||
_UNSLOTH_TORCH_OVERRIDES=""
|
||||
trap _on_install_exit EXIT
|
||||
|
||||
# ── Helper: download a URL to a file (supports curl and wget) ──
|
||||
|
|
@ -663,7 +711,7 @@ POLL_INTERVAL_SEC=0.25
|
|||
LOG_FILE="$DATA_DIR/studio.log"
|
||||
# why: in env-override mode multiple installs share an OS user; namespace the
|
||||
# lock and remember our own healthy port so we never attach to an unrelated
|
||||
# Studio listening on the global 8888..8908 range.
|
||||
# Unsloth listening on the global 8888..8908 range.
|
||||
LOCK_DIR="${XDG_RUNTIME_DIR:-/tmp}/unsloth-studio-launcher-$(id -u).lock"
|
||||
PORT_FILE=""
|
||||
# why: gate on the install-time mode (baked above) instead of the runtime env
|
||||
|
|
@ -734,7 +782,7 @@ _candidate_ports() {
|
|||
_find_healthy_port() {
|
||||
if [ -n "$PORT_FILE" ] && [ -f "$PORT_FILE" ]; then
|
||||
# why: env-mode installs only attach to a port we previously launched
|
||||
# ourselves; never to a sibling Studio that happens to be healthy.
|
||||
# ourselves; never to a sibling Unsloth that happens to be healthy.
|
||||
_p=$(cat "$PORT_FILE" 2>/dev/null || true)
|
||||
case "$_p" in
|
||||
''|*[!0-9]*) ;;
|
||||
|
|
@ -901,7 +949,7 @@ _acquire_lock() {
|
|||
# Lock dir exists -- check if owner is still alive
|
||||
_old_pid=$(cat "$LOCK_DIR/pid" 2>/dev/null || true)
|
||||
if [ -n "$_old_pid" ] && kill -0 "$_old_pid" 2>/dev/null; then
|
||||
# Another launcher is running; wait for it to bring Studio up
|
||||
# Another launcher is running; wait for it to bring Unsloth up
|
||||
_deadline=$(($(date +%s) + TIMEOUT_SEC))
|
||||
while [ "$(date +%s)" -lt "$_deadline" ]; do
|
||||
_port=$(_find_healthy_port) && {
|
||||
|
|
@ -1371,7 +1419,7 @@ WSLPS1_EOF
|
|||
# shortcut wasn't created; tell the user how to launch / re-enable it.
|
||||
if [ "$_css_created" -ne 1 ]; then
|
||||
substep "Couldn't create the Windows shortcut (WSL interop may be disabled)." "$C_WARN"
|
||||
substep " Launch Studio from Windows: wsl -d \"$_css_distro\" -- bash -lc 'unsloth studio'" "$C_WARN"
|
||||
substep " Launch Unsloth from Windows: wsl -d \"$_css_distro\" -- bash -lc 'unsloth studio'" "$C_WARN"
|
||||
substep " (re-enable shortcuts: turn WSL interop back on, e.g. run 'wsl --shutdown' then reopen WSL.)" "$C_WARN"
|
||||
fi
|
||||
fi
|
||||
|
|
@ -1439,7 +1487,7 @@ if [ "$MAC_INTEL" = true ]; then
|
|||
echo ""
|
||||
echo " NOTE: Intel Mac (x86_64) detected."
|
||||
echo " PyTorch is unavailable for this platform (dropped Jan 2024)."
|
||||
echo " Studio will install in GGUF-only mode."
|
||||
echo " Unsloth will install in GGUF-only mode."
|
||||
echo " Chat, inference via GGUF, and data recipes will work."
|
||||
echo " Training requires Apple Silicon or Linux with GPU."
|
||||
echo ""
|
||||
|
|
@ -1573,6 +1621,12 @@ _has_usable_nvidia_gpu() {
|
|||
# the STUDIO_HOME mkdir/venv so the origin distro is untouched.
|
||||
_maybe_reroute_strixhalo_to_2404() {
|
||||
[ "${OS:-}" = "wsl" ] || return 0
|
||||
# An explicit index pin skips every GPU-driven reroute (same contract as
|
||||
# the later Radeon/Strix guard): the pin is honored in THIS distro rather
|
||||
# than probing the GPU and switching distributions. Whitespace-only
|
||||
# overrides do not gate (parity with get_torch_index_url).
|
||||
_rr_pin=$(printf '%s' "${UNSLOTH_TORCH_INDEX_URL:-}${UNSLOTH_TORCH_INDEX_FAMILY:-}" | tr -d '[:space:]')
|
||||
[ -n "$_rr_pin" ] && return 0
|
||||
[ "${SKIP_TORCH:-false}" = "false" ] || return 0
|
||||
[ "${UNSLOTH_SKIP_ROCM_WSL_SETUP:-0}" = "1" ] && return 0
|
||||
[ "${UNSLOTH_WSL_REROUTED:-0}" = "1" ] && return 0
|
||||
|
|
@ -1634,6 +1688,10 @@ _maybe_reroute_strixhalo_to_2404() {
|
|||
# Forward explicit ROCm-bootstrap consent (e.g. Tauri) so the child auto-enables the
|
||||
# GPU instead of falling back to the desktop-app prompt path.
|
||||
[ "${UNSLOTH_ROCM_WSL_AUTO:-0}" = "1" ] && _rr_exports="$_rr_exports; export UNSLOTH_ROCM_WSL_AUTO=1"
|
||||
# Forward a pinned torch index into the rerouted distro; dropping it would
|
||||
# silently revert the child install to auto-detection.
|
||||
[ -n "${UNSLOTH_TORCH_INDEX_URL:-}" ] && _rr_exports="$_rr_exports; export UNSLOTH_TORCH_INDEX_URL=$(_rr_q "$UNSLOTH_TORCH_INDEX_URL")"
|
||||
[ -n "${UNSLOTH_TORCH_INDEX_FAMILY:-}" ] && _rr_exports="$_rr_exports; export UNSLOTH_TORCH_INDEX_FAMILY=$(_rr_q "$UNSLOTH_TORCH_INDEX_FAMILY")"
|
||||
[ "$_SKIP_AUTOSTART" = true ] && _rr_exports="$_rr_exports; export UNSLOTH_SKIP_AUTOSTART=1"
|
||||
_rr_args=""
|
||||
[ "$PACKAGE_NAME" != "unsloth" ] && _rr_args="$_rr_args --package $(_rr_q "$PACKAGE_NAME")"
|
||||
|
|
@ -1671,7 +1729,7 @@ _maybe_reroute_strixhalo_to_2404() {
|
|||
_maybe_reroute_strixhalo_to_2404 || true
|
||||
|
||||
# ── Check system dependencies ──
|
||||
# cmake/git are only needed to *build* llama.cpp from source. Studio downloads a
|
||||
# cmake/git are only needed to *build* llama.cpp from source. Unsloth downloads a
|
||||
# prebuilt by default, and setup.sh self-skips the source build when they're
|
||||
# absent -- so macOS doesn't block on cmake (requiring it would force a manual
|
||||
# Homebrew install). Linux keeps requiring them; its package manager has them.
|
||||
|
|
@ -1821,11 +1879,13 @@ tauri_log "STEP" "Creating virtual environment"
|
|||
mkdir -p "$STUDIO_HOME"
|
||||
|
||||
_MIGRATED=false
|
||||
# Empty so an inherited value can never masquerade as a probed torch version.
|
||||
_PREV_TORCH_VER=""
|
||||
|
||||
if [ -x "$VENV_DIR/bin/python" ]; then
|
||||
# why: matching guard to the .venv branch below -- in env-mode
|
||||
# $STUDIO_HOME is a user-chosen workspace, so refuse to nuke an
|
||||
# existing $STUDIO_HOME/unsloth_studio that lacks Studio sentinels.
|
||||
# existing $STUDIO_HOME/unsloth_studio that lacks Unsloth sentinels.
|
||||
# Accept the in-VENV ownership marker so partial-install retries are
|
||||
# not blocked. Sentinels must be regular files: -f follows symlinks
|
||||
# to files (the legitimate ln -s shim shape) but rejects directories
|
||||
|
|
@ -1838,6 +1898,12 @@ if [ -x "$VENV_DIR/bin/python" ]; then
|
|||
echo " Move it aside or choose an empty UNSLOTH_STUDIO_HOME." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Record the existing venv's torch BEFORE the replacement moves it aside: a re-run
|
||||
# rebuilds the venv for clean state, but must keep the torch release the user
|
||||
# already has (see _previous_torch_pin below). Last line only: sitecustomize or
|
||||
# import-hook noise on stdout must not corrupt the version.
|
||||
_PREV_TORCH_VER=$("$VENV_DIR/bin/python" -c \
|
||||
"import torch; print(torch.__version__)" 2>/dev/null | tail -n 1 || true)
|
||||
# New layout already exists — replace only after preserving rollback copy.
|
||||
substep "preserving existing environment for rollback..."
|
||||
_start_studio_venv_replacement "$VENV_DIR"
|
||||
|
|
@ -1846,7 +1912,7 @@ elif [ "$_STUDIO_HOME_REDIRECT" != "env" ] && [ -x "$STUDIO_HOME/.venv/bin/pytho
|
|||
# Skip in env-mode so we don't rm -rf an unrelated .venv at the
|
||||
# workspace root (e.g. user's existing project Python venv).
|
||||
# In no-torch mode, a missing torch package is expected; validate Python only.
|
||||
substep "found legacy Studio environment, validating..."
|
||||
substep "found legacy Unsloth environment, validating..."
|
||||
_legacy_ok=false
|
||||
if [ "$SKIP_TORCH" = true ]; then
|
||||
if "$STUDIO_HOME/.venv/bin/python" -c "import sys; print(sys.executable)" >/dev/null 2>&1; then
|
||||
|
|
@ -1903,7 +1969,7 @@ if [ ! -x "$VENV_DIR/bin/python" ]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
# Mark the freshly-created venv as Studio-owned so a partial install can be
|
||||
# Mark the freshly-created venv as Unsloth-owned so a partial install can be
|
||||
# repaired by re-running install.sh; the env-mode deletion guard above accepts
|
||||
# this marker as the primary sentinel.
|
||||
if [ -x "$VENV_DIR/bin/python" ]; then
|
||||
|
|
@ -1991,6 +2057,15 @@ if [ "$SKIP_TORCH" = false ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; t
|
|||
TORCH_CONSTRAINT="torch>=2.6,<2.11.0"
|
||||
fi
|
||||
fi
|
||||
# Companion (torchvision/torchaudio) constraints, bounded to torch's window.
|
||||
# torchaudio 2.11 dropped its exact torch pin, so a bare companion next to a
|
||||
# <2.11-capped torch resolves torchaudio 2.11 (verified: cpu leaf installed
|
||||
# torch 2.10.0+cpu with torchaudio 2.11.0+cpu). torchvision still exact-pins
|
||||
# torch and self-corrects, but is bounded for symmetry. Widened alongside the
|
||||
# cu* torch window below; the torch-2.11 AMD paths (rocm7.2 / per-gfx / Strix)
|
||||
# pin their own trio.
|
||||
TORCHVISION_CONSTRAINT="torchvision>=0.19,<0.26.0"
|
||||
TORCHAUDIO_CONSTRAINT="torchaudio>=2.4,<2.11.0"
|
||||
|
||||
# ── Resolve repo root (for --local installs) ──
|
||||
_REPO_ROOT="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)"
|
||||
|
|
@ -2059,6 +2134,24 @@ _has_amd_rocm_gpu() {
|
|||
get_torch_index_url() {
|
||||
_base="${UNSLOTH_PYTORCH_MIRROR:-https://download.pytorch.org/whl}"
|
||||
_base="${_base%/}"
|
||||
# Explicit override -- skip ALL GPU probing (headless / container / CI / cross-install).
|
||||
# UNSLOTH_TORCH_INDEX_URL wins (full URL, verbatim); _FAMILY is the leaf (cpu, cu128, ...)
|
||||
# appended to the mirror base. Trim whitespace so a whitespace-only value is unset.
|
||||
_url="${UNSLOTH_TORCH_INDEX_URL:-}"
|
||||
_url="${_url#"${_url%%[![:space:]]*}"}"; _url="${_url%"${_url##*[![:space:]]}"}"
|
||||
if [ -n "$_url" ]; then
|
||||
# Trim trailing PATH slashes (a multi-slash path 404s on strict pip proxies) while
|
||||
# preserving a ?query/#fragment token (a whole-URL strip would eat a "/"-ending token).
|
||||
_url=$(_trim_index_path_slashes "$_url")
|
||||
echo "$_url"; return
|
||||
fi
|
||||
_family="${UNSLOTH_TORCH_INDEX_FAMILY:-}"
|
||||
_family="${_family#"${_family%%[![:space:]]*}"}"; _family="${_family%"${_family##*[![:space:]]}"}"
|
||||
if [ -n "$_family" ]; then
|
||||
while [ "${_family#/}" != "$_family" ]; do _family="${_family#/}"; done
|
||||
while [ "${_family%/}" != "$_family" ]; do _family="${_family%/}"; done
|
||||
echo "$_base/$_family"; return
|
||||
fi
|
||||
# macOS: always CPU (no CUDA support)
|
||||
case "$(uname -s)" in Darwin) echo "$_base/cpu"; return ;; esac
|
||||
# Try nvidia-smi -- require the binary to actually list a usable GPU.
|
||||
|
|
@ -2187,16 +2280,155 @@ _torch_flavor_tag() {
|
|||
esac
|
||||
}
|
||||
|
||||
# Final path segment of a wheel index URL ($1), lowercased, query/fragment stripped first
|
||||
# so a token-authenticated pin (.../cu128?token=x) classifies as cu128 (else it reinstalls
|
||||
# every update). Classification only. Shared with the py / ps1 leaf extractors.
|
||||
_torch_index_url_leaf() {
|
||||
_tl_u="${1%%\?*}"
|
||||
_tl_u="${_tl_u%%#*}"
|
||||
# Strip ALL trailing slashes, not one: .../rocm7.2// must yield rocm7.2, not an empty leaf.
|
||||
while [ -n "$_tl_u" ] && [ "${_tl_u%/}" != "$_tl_u" ]; do
|
||||
_tl_u="${_tl_u%/}"
|
||||
done
|
||||
printf '%s' "${_tl_u##*/}" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
# True (exit 0) when a lowercased leaf is an EXACT pip ROCm family: rocm<digits>[.<digits>]
|
||||
# or a gfx ARCHITECTURE leaf (gfx followed by a digit: gfx90a, gfx1151, gfx120x-all). A leaf
|
||||
# that merely starts with rocm/gfx (rocm7.2-private, gfx-private) is a custom verbatim pin.
|
||||
# Matches the py / ps1 sides.
|
||||
_is_pip_rocm_family_leaf() {
|
||||
case "$1" in
|
||||
gfx[0-9]*) return 0 ;;
|
||||
rocm[0-9]*)
|
||||
# Exact rocm<digits>[.<digits>]: both major and minor must be non-empty all-digits
|
||||
# (rocm7., rocm7.2.1, rocm7.2-private are all custom pins, not a family).
|
||||
_rocm_rest="${1#rocm}"
|
||||
case "$_rocm_rest" in
|
||||
*.*.*) return 1 ;;
|
||||
*.*)
|
||||
_rocm_minor="${_rocm_rest#*.}"
|
||||
case "${_rocm_rest%%.*}" in "" | *[!0-9]*) return 1 ;; esac
|
||||
case "$_rocm_minor" in "" | *[!0-9]*) return 1 ;; esac
|
||||
;;
|
||||
*[!0-9]*) return 1 ;;
|
||||
esac
|
||||
return 0
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Whether release base $1 (X.Y[.Z...]) falls inside constraint window $2
|
||||
# ("torch>=A.B[.C],<D.E.F"). Compares at major.minor granularity, which is exact
|
||||
# for the windows this script uses (ceilings are always X.Y.0); a non-.0 ceiling
|
||||
# would only make this conservative (excludes the whole ceiling minor). Anything
|
||||
# unparseable answers "no" so the caller fails toward the supported range.
|
||||
_torch_release_in_window() {
|
||||
_trw_con="$2"
|
||||
case "$_trw_con" in
|
||||
"torch>="*",<"*) ;;
|
||||
*) echo "no"; return ;;
|
||||
esac
|
||||
_trw_floor="${_trw_con#torch>=}"; _trw_floor="${_trw_floor%%,*}"
|
||||
_trw_ceil="${_trw_con##*,<}"
|
||||
_v_maj="${1%%.*}"; _v_rest="${1#*.}"; _v_min="${_v_rest%%.*}"
|
||||
_f_maj="${_trw_floor%%.*}"; _f_rest="${_trw_floor#*.}"; _f_min="${_f_rest%%.*}"
|
||||
_c_maj="${_trw_ceil%%.*}"; _c_rest="${_trw_ceil#*.}"; _c_min="${_c_rest%%.*}"
|
||||
for _trw_n in "$_v_maj" "$_v_min" "$_f_maj" "$_f_min" "$_c_maj" "$_c_min"; do
|
||||
case "$_trw_n" in ''|*[!0-9]*) echo "no"; return ;; esac
|
||||
done
|
||||
if [ "$_v_maj" -gt "$_f_maj" ] || { [ "$_v_maj" -eq "$_f_maj" ] && [ "$_v_min" -ge "$_f_min" ]; }; then
|
||||
if [ "$_v_maj" -lt "$_c_maj" ] || { [ "$_v_maj" -eq "$_c_maj" ] && [ "$_v_min" -lt "$_c_min" ]; }; then
|
||||
echo "yes"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
echo "no"
|
||||
}
|
||||
|
||||
# Keep the previous venv's torch on a re-run: echo "torch==X.Y.Z" when the probed
|
||||
# version ($1) is inside the active constraint window ($2), else "". The RELEASE is kept
|
||||
# regardless of flavor tag; the pin installs from the freshly chosen index, so flavor
|
||||
# follows the machine (cpu <-> cuda, cu126 -> cu130, PyPI bare -> +cu130) while the
|
||||
# release follows the user. Gating on flavor was wrong: a PyPI torch reports a BARE
|
||||
# version (on Linux the PyPI wheel IS CUDA), misclassified "cpu", so a healthy 2.10 on a
|
||||
# cu130 host was moved to 2.11. Per-leaf floors still win (rocm7.2 / gfx >=2.11 for the
|
||||
# Strix _grouped_mm fix, out-of-window manual installs) and are never pinned; the caller's
|
||||
# _PREV_FALLBACK_CONSTRAINT installs the newest supported release when the index lacks the
|
||||
# exact one. Opt out with UNSLOTH_TORCH_UPGRADE=1.
|
||||
_previous_torch_pin() {
|
||||
_ptp_ver="$1"
|
||||
_ptp_con="$2"
|
||||
[ -n "$_ptp_ver" ] || { echo ""; return; }
|
||||
[ "${UNSLOTH_TORCH_UPGRADE:-0}" = "1" ] && { echo ""; return; }
|
||||
_ptp_base="${_ptp_ver%%+*}"
|
||||
# Base must be a plain numeric release (X.Y[.Z]); probe noise and
|
||||
# nightly/dev/source builds (2.11.0.dev20250704, 2.9.0a0) must never
|
||||
# become a pin -- no stable index carries them, so pinning would only
|
||||
# print "keeping it" and then burn a doomed resolve before falling back.
|
||||
case "$_ptp_base" in
|
||||
*[!0-9.]* | *..* | .* | *.) echo ""; return ;;
|
||||
[0-9]*.[0-9]*) ;;
|
||||
*) echo ""; return ;;
|
||||
esac
|
||||
[ "$(_torch_release_in_window "$_ptp_base" "$_ptp_con")" = "yes" ] || { echo ""; return; }
|
||||
echo "torch==$_ptp_base"
|
||||
}
|
||||
|
||||
# Install torch from TORCH_INDEX_URL honoring a kept-release pin: with _PREV_TORCH_PIN
|
||||
# set, TORCH_CONSTRAINT is the exact previous release; fall back to the supported range
|
||||
# if the index lacks it (pruned mirror) rather than failing. Used by every --default-index
|
||||
# path (NVIDIA cu*, AMD rocm/gfx fallbacks, cpu/mac, ROCm repairs) so preservation is
|
||||
# uniform. Extra args (e.g. --force-reinstall) are passed through to uv.
|
||||
_install_torch_default_index() {
|
||||
if [ -n "$_PREV_TORCH_PIN" ]; then
|
||||
# Pair the companions with the kept torch minor: torchaudio no longer
|
||||
# exact-pins torch in its metadata, so leaving it unconstrained resolves
|
||||
# a newer mismatched build (a kept torch 2.9.0 pulled torchaudio 2.11.0).
|
||||
_itdi_base="${_PREV_TORCH_PIN#torch==}"
|
||||
_itdi_minor="${_itdi_base#*.}"
|
||||
_itdi_minor="${_itdi_minor%%.*}"
|
||||
_itdi_tv="torchvision"
|
||||
_itdi_ta="torchaudio"
|
||||
case "$_itdi_base" in
|
||||
2.*)
|
||||
_itdi_tv="torchvision==0.$((_itdi_minor + 15)).*"
|
||||
_itdi_ta="torchaudio==2.${_itdi_minor}.*"
|
||||
;;
|
||||
esac
|
||||
if ! run_install_cmd_retry "install PyTorch (kept release)" uv pip install --python "$_VENV_PY" "$TORCH_CONSTRAINT" "$_itdi_tv" "$_itdi_ta" \
|
||||
--default-index "$TORCH_INDEX_URL" "$@"; then
|
||||
substep "[WARN] $_PREV_TORCH_PIN is not installable from $(_strip_index_url_credentials "$TORCH_INDEX_URL") -- installing the newest supported release instead" "$C_WARN"
|
||||
TORCH_CONSTRAINT="$_PREV_FALLBACK_CONSTRAINT"
|
||||
_PREV_TORCH_PIN=""
|
||||
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" "$TORCH_CONSTRAINT" "$TORCHVISION_CONSTRAINT" "$TORCHAUDIO_CONSTRAINT" \
|
||||
--default-index "$TORCH_INDEX_URL" "$@"
|
||||
fi
|
||||
else
|
||||
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" "$TORCH_CONSTRAINT" "$TORCHVISION_CONSTRAINT" "$TORCHAUDIO_CONSTRAINT" \
|
||||
--default-index "$TORCH_INDEX_URL" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Expected tag from the index leaf ($1): cuXXX / cpu / rocm (rocmX.Y and gfx* ->
|
||||
# rocm). Empty on an unknown leaf (odd mirror) so the repair safely no-ops.
|
||||
_expected_torch_flavor_tag() {
|
||||
_u="${1%/}"
|
||||
_leaf="${_u##*/}"
|
||||
_leaf=$(_torch_index_url_leaf "$1")
|
||||
case "$_leaf" in
|
||||
cu[0-9]*) echo "$_leaf" ;;
|
||||
cpu) echo "cpu" ;;
|
||||
rocm*|gfx*) echo "rocm" ;;
|
||||
*) echo "" ;;
|
||||
cu[0-9]*)
|
||||
# Exact cu + digits only; a cu*-suffixed leaf (cu128-private) -> "" (custom),
|
||||
# else a correct +cu128 wheel is force-reinstalled every run.
|
||||
case "${_leaf#cu}" in
|
||||
*[!0-9]*) echo "" ;;
|
||||
*) echo "$_leaf" ;;
|
||||
esac
|
||||
;;
|
||||
cpu) echo "cpu" ;;
|
||||
# Exact rocm/gfx families only; a custom rocm*-suffixed leaf -> "" (custom).
|
||||
*)
|
||||
if _is_pip_rocm_family_leaf "$_leaf"; then echo "rocm"; else echo ""; fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
|
|
@ -2206,14 +2438,42 @@ _expected_torch_flavor_tag() {
|
|||
# fresh-install paths above already use -- so a stale wheel is auto-repairable.
|
||||
# Unknown/odd-mirror leaves -> no, so we warn rather than risk a wrong reinstall.
|
||||
_torch_index_repairable() {
|
||||
_u="${1%/}"
|
||||
_leaf="${_u##*/}"
|
||||
_leaf=$(_torch_index_url_leaf "$1")
|
||||
case "$_leaf" in
|
||||
cu[0-9]*|rocm[0-9]*|gfx*) echo "yes" ;;
|
||||
*) echo "no" ;;
|
||||
cu[0-9]*) echo "yes" ;;
|
||||
# Only EXACT rocm/gfx families resolve via --default-index; a suffixed leaf is verbatim.
|
||||
*)
|
||||
if _is_pip_rocm_family_leaf "$_leaf"; then echo "yes"; else echo "no"; fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Remove credentials from a wheel index URL ($1) so an authenticated pin never leaks:
|
||||
# drops userinfo AND query/fragment; scheme/host/path stay exact. Shared with py / ps1.
|
||||
_strip_index_url_credentials() {
|
||||
_sic_url="$1"
|
||||
case "$_sic_url" in
|
||||
*://*) ;;
|
||||
*) printf '%s' "$_sic_url"; return ;;
|
||||
esac
|
||||
_sic_scheme="${_sic_url%%://*}"
|
||||
_sic_rest="${_sic_url#*://}"
|
||||
# Drop query / fragment (may hold auth tokens).
|
||||
_sic_rest="${_sic_rest%%\?*}"
|
||||
_sic_rest="${_sic_rest%%#*}"
|
||||
_sic_auth="${_sic_rest%%/*}"
|
||||
# Drop user:pass@ userinfo if present.
|
||||
case "$_sic_auth" in
|
||||
*@*) _sic_host="${_sic_auth##*@}" ;;
|
||||
*) _sic_host="$_sic_auth" ;;
|
||||
esac
|
||||
if [ "$_sic_auth" = "$_sic_rest" ]; then
|
||||
printf '%s://%s' "$_sic_scheme" "$_sic_host"
|
||||
else
|
||||
printf '%s://%s/%s' "$_sic_scheme" "$_sic_host" "${_sic_rest#*/}"
|
||||
fi
|
||||
}
|
||||
|
||||
get_radeon_wheel_url() {
|
||||
# Only meaningful on Linux. Picks a repo.radeon.com base URL whose listing
|
||||
# contains torch wheels. Tries paths like rocm-rel-7.2.1/, rocm-rel-7.2/,
|
||||
|
|
@ -2335,7 +2595,7 @@ _pick_radeon_wheel() {
|
|||
# the installer -- always returns 0. Runs the idempotent helper (ROCm 7.2 +
|
||||
# librocdxg), then sources the env it persisted so detection finds the GPU.
|
||||
# Export the ROCm-on-WSL env into this process and persist it to /etc/profile.d
|
||||
# so non-login Studio/llama launches inherit it. Idempotent (writes only when
|
||||
# so non-login Unsloth/llama launches inherit it. Idempotent (writes only when
|
||||
# the drop-in is missing); no-op without librocdxg, so never fires off WSL.
|
||||
# /etc/profile.d is root-owned -- sudo-tee when not root, else ROCm vanishes
|
||||
# after this shell on a non-root reinstall. Best-effort either way.
|
||||
|
|
@ -2380,7 +2640,7 @@ _maybe_bootstrap_rocm_wsl() {
|
|||
rocminfo 2>/dev/null | awk '/Name:[[:space:]]*gfx[1-9]/ && !/generic/{found=1} END{exit !found}'; then
|
||||
# rocminfo may work only via the transient env _ensure_rocm_probe_env
|
||||
# just set, which dies with the installer. Persist the drop-in so login
|
||||
# shells (Studio, llama.cpp) inherit it -- else a reinstall over an
|
||||
# shells (Unsloth, llama.cpp) inherit it -- else a reinstall over an
|
||||
# existing /opt/rocm (uninstall keeps ROCm but drops it) loses the GPU.
|
||||
_persist_rocm_wsl_dropin
|
||||
return 0
|
||||
|
|
@ -2402,7 +2662,7 @@ _maybe_bootstrap_rocm_wsl() {
|
|||
# shellcheck disable=SC1091
|
||||
. /etc/profile.d/unsloth-rocm-wsl.sh || true
|
||||
else
|
||||
# librocdxg present but the env drop-in is gone (e.g. a Studio
|
||||
# librocdxg present but the env drop-in is gone (e.g. an Unsloth
|
||||
# uninstall removed it while keeping shared ROCm). Restore the env.
|
||||
_persist_rocm_wsl_dropin
|
||||
fi
|
||||
|
|
@ -2459,7 +2719,19 @@ _maybe_bootstrap_rocm_wsl() {
|
|||
[ -n "$_rw_tmp" ] && rm -f "$_rw_tmp"
|
||||
return 0
|
||||
}
|
||||
_maybe_bootstrap_rocm_wsl || true
|
||||
# When the caller pins the wheel index (UNSLOTH_TORCH_INDEX_URL / _FAMILY), honour it
|
||||
# everywhere: skip the WSL ROCm bootstrap and the Radeon/Strix reroute below (which would
|
||||
# re-probe the GPU and overwrite the pin). Trim whitespace first (parity with
|
||||
# get_torch_index_url): a whitespace-only override is unset there, so must not flip this true.
|
||||
_torch_index_pinned=false
|
||||
_ti_url_trim="${UNSLOTH_TORCH_INDEX_URL:-}"
|
||||
_ti_url_trim="${_ti_url_trim#"${_ti_url_trim%%[![:space:]]*}"}"; _ti_url_trim="${_ti_url_trim%"${_ti_url_trim##*[![:space:]]}"}"
|
||||
_ti_family_trim="${UNSLOTH_TORCH_INDEX_FAMILY:-}"
|
||||
_ti_family_trim="${_ti_family_trim#"${_ti_family_trim%%[![:space:]]*}"}"; _ti_family_trim="${_ti_family_trim%"${_ti_family_trim##*[![:space:]]}"}"
|
||||
if [ -n "$_ti_url_trim" ] || [ -n "$_ti_family_trim" ]; then
|
||||
_torch_index_pinned=true
|
||||
fi
|
||||
[ "$_torch_index_pinned" = true ] || _maybe_bootstrap_rocm_wsl || true
|
||||
|
||||
TORCH_INDEX_URL=$(get_torch_index_url)
|
||||
|
||||
|
|
@ -2470,24 +2742,74 @@ TORCH_INDEX_URL=$(get_torch_index_url)
|
|||
# whose base path happens to contain "rocm" or "gfx" must not mislabel a
|
||||
# cu*/cpu index as ROCm (radeon repo URLs end in rocm-rel-X.Y/, Strix
|
||||
# overrides in gfxNNNN/, so the trailing slash is stripped first).
|
||||
_torch_index_leaf="${TORCH_INDEX_URL%/}"
|
||||
# Lowercase the leaf so every gfx*/rocm*/cu* arm matches regardless of case (canonical AMD
|
||||
# RDNA4 leaf is gfx120X-all). CUDA is branded only on a real cu[0-9]* leaf, so a mirror
|
||||
# leaf (/current) does NOT commit a CUDA backend; an unknown leaf leaves the var unset so
|
||||
# the stack probes the GPU. Query/fragment dropped first, then ALL trailing slashes (in
|
||||
# lockstep with the shared _torch_index_url_leaf extractor).
|
||||
_torch_index_leaf="${TORCH_INDEX_URL%%\?*}"
|
||||
_torch_index_leaf="${_torch_index_leaf%%#*}"
|
||||
# Strip ALL trailing slashes, not one: .../cu128// must yield cu128, not an empty leaf.
|
||||
while [ -n "$_torch_index_leaf" ] && [ "${_torch_index_leaf%/}" != "$_torch_index_leaf" ]; do
|
||||
_torch_index_leaf="${_torch_index_leaf%/}"
|
||||
done
|
||||
_torch_index_leaf="${_torch_index_leaf##*/}"
|
||||
_torch_index_leaf=$(printf '%s' "$_torch_index_leaf" | tr '[:upper:]' '[:lower:]')
|
||||
case "$_torch_index_leaf" in
|
||||
rocm*|gfx*) export UNSLOTH_TORCH_BACKEND="rocm" ;;
|
||||
cpu) export UNSLOTH_TORCH_BACKEND="cpu" ;;
|
||||
*) export UNSLOTH_TORCH_BACKEND="cuda" ;;
|
||||
cu[0-9]*) export UNSLOTH_TORCH_BACKEND="cuda" ;;
|
||||
# Unknown leaf (odd mirror, /current): unset so a stale inherited value can't leak and
|
||||
# the stack probes the GPU.
|
||||
*) unset UNSLOTH_TORCH_BACKEND ;;
|
||||
esac
|
||||
|
||||
# rocm7.2 ships torch 2.11.0 -- adjust the constraint to allow it.
|
||||
# All other ROCm tags and CUDA stay within <2.11.0.
|
||||
case "$TORCH_INDEX_URL" in
|
||||
*/rocm7.2) TORCH_CONSTRAINT="torch>=2.11.0,<2.12.0" ;;
|
||||
# Whether TORCH_INDEX_URL names an actual pip ROCm family (rocm<digit>* / gfx*), gating the
|
||||
# ROCm-only side effects below (AMD bitsandbytes, ROCm-torch repair). Digit-gated so a leaf
|
||||
# merely STARTING with "rocm" isn't force-repaired from the wrong path.
|
||||
if _is_pip_rocm_family_leaf "$_torch_index_leaf"; then
|
||||
_torch_index_is_rocm_family=true
|
||||
else
|
||||
_torch_index_is_rocm_family=false
|
||||
fi
|
||||
|
||||
# rocm7.2 and the per-gfx indexes with the _grouped_mm <2.11 bug (gfx120X-all, gfx1151,
|
||||
# gfx1150) ship torch 2.11.0 -- raise the floor (also covers a pinned override that skipped
|
||||
# the Strix reroute). Pin the companions too: the per-gfx index publishes them independently
|
||||
# and a bare name can resolve a 2.12 ABI-mismatched wheel. Match on the FINAL leaf so a
|
||||
# custom mirror with a gfx/rocm7.2 path segment but a cu*/cpu family isn't forced.
|
||||
case "$_torch_index_leaf" in
|
||||
rocm7.2|gfx120x-all|gfx1151|gfx1150)
|
||||
TORCH_CONSTRAINT="torch>=2.11.0,<2.12.0"
|
||||
TORCHVISION_CONSTRAINT="torchvision>=0.26.0,<0.27.0"
|
||||
TORCHAUDIO_CONSTRAINT="torchaudio>=2.11.0,<2.12.0"
|
||||
;;
|
||||
# CUDA cu12x/cu13x indexes ship torch 2.11.x: widen the ceiling to <2.12.0 (matches
|
||||
# _CUDA_TORCH_PKG_SPEC) and widen the companions with it so the trio stays paired.
|
||||
cu[0-9]*)
|
||||
TORCH_CONSTRAINT="torch>=2.4,<2.12.0"
|
||||
TORCHVISION_CONSTRAINT="torchvision>=0.19,<0.27.0"
|
||||
TORCHAUDIO_CONSTRAINT="torchaudio>=2.4,<2.12.0"
|
||||
;;
|
||||
esac
|
||||
|
||||
# A pinned custom/unknown-leaf index (/simple, /current, /cu128-private) has no curated
|
||||
# companion set, so bound torchvision/torchaudio to the same <2.11 range the Python path pins
|
||||
# (else a mirror with newer companions resolves a 2.12 ABI-mismatched wheel). Known families
|
||||
# keep their curated companions above (_expected_torch_flavor_tag returns "" only for custom).
|
||||
if [ "$_torch_index_pinned" = true ] && \
|
||||
[ -z "$(_expected_torch_flavor_tag "$TORCH_INDEX_URL")" ]; then
|
||||
TORCHVISION_CONSTRAINT="torchvision>=0.19,<0.26.0"
|
||||
TORCHAUDIO_CONSTRAINT="torchaudio>=2.4,<2.11.0"
|
||||
fi
|
||||
|
||||
# Auto-detect GPU for AMD ROCm based
|
||||
# get_torch_index_url must have chosen */rocm*
|
||||
# (gfx in rocminfo or amd-smi list). Then require rocminfo "Marketing Name:.*Radeon".
|
||||
# Skipped when the index is pinned: an explicit override must not be rerouted to the
|
||||
# Radeon/Strix repos by GPU probing.
|
||||
_amd_gpu_radeon=false
|
||||
if [ "$_torch_index_pinned" = false ]; then
|
||||
case "$TORCH_INDEX_URL" in
|
||||
*/rocm*)
|
||||
if _has_amd_rocm_gpu && command -v rocminfo >/dev/null 2>&1 && \
|
||||
|
|
@ -2564,10 +2886,31 @@ case "$TORCH_INDEX_URL" in
|
|||
done
|
||||
TORCH_INDEX_URL="${_amd_strix_base}/${_strix_gfx}/"
|
||||
TORCH_CONSTRAINT="torch>=2.11.0,<2.12.0"
|
||||
# Pin companions to 2.11 (per-gfx index publishes them independently).
|
||||
TORCHVISION_CONSTRAINT="torchvision>=0.26.0,<0.27.0"
|
||||
TORCHAUDIO_CONSTRAINT="torchaudio>=2.11.0,<2.12.0"
|
||||
_amd_gpu_radeon=false
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi # _torch_index_pinned guard (Radeon + Strix reroute)
|
||||
# Re-run over an existing install: keep the previous venv's torch RELEASE; the fresh
|
||||
# index above supplies the right flavor for this machine. Evaluated HERE, after every
|
||||
# index/constraint decision including the Strix reroute, so the window checked is the
|
||||
# final one and a raised floor (rocm7.2 / Strix gfx) rejects an older release.
|
||||
# _PREV_FALLBACK_CONSTRAINT keeps the range so the install can fall back when the exact
|
||||
# release is not on the chosen index (mirrors may prune old wheels). Skipped for --no-torch.
|
||||
_PREV_TORCH_PIN=""
|
||||
_PREV_FALLBACK_CONSTRAINT="$TORCH_CONSTRAINT"
|
||||
if [ "$SKIP_TORCH" = false ]; then
|
||||
_prev_pin=$(_previous_torch_pin "$_PREV_TORCH_VER" "$TORCH_CONSTRAINT")
|
||||
if [ -n "$_prev_pin" ]; then
|
||||
_PREV_TORCH_PIN="$_prev_pin"
|
||||
TORCH_CONSTRAINT="$_prev_pin"
|
||||
substep "existing install has torch $_PREV_TORCH_VER -- keeping it (set UNSLOTH_TORCH_UPGRADE=1 to get the newest release)"
|
||||
fi
|
||||
fi
|
||||
|
||||
_TAURI_TORCH_INDEX_FAMILY=$(_tauri_torch_index_family "$TORCH_INDEX_URL")
|
||||
if [ "$_amd_gpu_radeon" = true ] && [ "$SKIP_TORCH" = false ]; then
|
||||
_TAURI_TORCH_INDEX_FAMILY="radeon"
|
||||
|
|
@ -2697,7 +3040,7 @@ case "$TORCH_INDEX_URL" in
|
|||
if [ "$_amd_gpu_radeon" = true ]; then
|
||||
substep "wheels: repo.radeon.com (Radeon)"
|
||||
else
|
||||
substep "wheels: $TORCH_INDEX_URL"
|
||||
substep "wheels: $(_strip_index_url_credentials "$TORCH_INDEX_URL")"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
|
@ -2705,9 +3048,46 @@ esac
|
|||
# ── Install unsloth directly into the venv (no activation needed) ──
|
||||
tauri_log "STEP" "Installing PyTorch"
|
||||
_VENV_PY="$VENV_DIR/bin/python"
|
||||
|
||||
# A released unsloth wheel can pin an older torch (unsloth 2026.7.2 declares
|
||||
# torch<2.11.0); a with-deps PyPI resolve then downgrades the whole trio,
|
||||
# swapping the pinned +cuXXX/+rocm build for PyPI's default. The flavor guard
|
||||
# below misses this (PyPI's torch 2.10 default is itself cu128-flavored), so
|
||||
# freeze the trio via uv --overrides (overrides replace dependency requirements
|
||||
# during resolution) while unsloth's other deps resolve normally. Sets
|
||||
# _UNSLOTH_TORCH_OVERRIDES from the trio in the venv; every with-deps unsloth
|
||||
# install (migrated and fresh) must call this before resolving and rm it after.
|
||||
_build_unsloth_torch_overrides() {
|
||||
_UNSLOTH_TORCH_OVERRIDES=""
|
||||
[ "$SKIP_TORCH" = false ] || return 0
|
||||
_torch_trio_pins=$("$_VENV_PY" -c "
|
||||
from importlib.metadata import version, PackageNotFoundError
|
||||
for _p in ('torch', 'torchvision', 'torchaudio'):
|
||||
try:
|
||||
print(_p + '==' + version(_p))
|
||||
except PackageNotFoundError:
|
||||
pass
|
||||
" 2>/dev/null) || _torch_trio_pins=""
|
||||
case "$_torch_trio_pins" in
|
||||
torch==*)
|
||||
_UNSLOTH_TORCH_OVERRIDES=$(mktemp)
|
||||
printf '%s\n' "$_torch_trio_pins" > "$_UNSLOTH_TORCH_OVERRIDES"
|
||||
# The CLI --overrides flag replaces any UV_OVERRIDE env file (same
|
||||
# uv setting; macOS arm64 exports one here), so fold its pins in.
|
||||
# awk, not cat: it drops inherited torch-trio lines (uv intersects
|
||||
# duplicate overrides, so a conflicting pin would make resolution
|
||||
# unsatisfiable) and newline-terminates the last line so an
|
||||
# unterminated file cannot join two requirements into one.
|
||||
for _ov_file in ${UV_OVERRIDE:-}; do
|
||||
[ -f "$_ov_file" ] && awk '!/^[[:space:]]*torch(vision|audio)?([[:space:]<>=!~;@[]|$)/' "$_ov_file" >> "$_UNSLOTH_TORCH_OVERRIDES"
|
||||
done
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ "$_MIGRATED" = true ]; then
|
||||
# Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state
|
||||
# in the new venv location, while preserving existing torch/CUDA
|
||||
# Migrated env: force-reinstall unsloth+unsloth-zoo for a clean state, preserving
|
||||
# existing torch/CUDA unless the ROCm repair below fires.
|
||||
substep "upgrading unsloth in migrated environment..."
|
||||
if [ "$SKIP_TORCH" = true ]; then
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps (current
|
||||
|
|
@ -2716,7 +3096,7 @@ if [ "$_MIGRATED" = true ]; then
|
|||
# to prevent transitive torch resolution.
|
||||
run_install_cmd_retry "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
|
||||
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
||||
"unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2"
|
||||
"unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3"
|
||||
# Resolve pydantic WITH deps so pip pins pydantic-core to the
|
||||
# matching version (no-torch-runtime.txt below is --no-deps).
|
||||
# All transitive deps are torch-free.
|
||||
|
|
@ -2729,9 +3109,13 @@ if [ "$_MIGRATED" = true ]; then
|
|||
else
|
||||
# Pin mlx-lm away from 0.31.3 here too: a curl-piped migration has no
|
||||
# overrides file, so UV_OVERRIDE is unset and this positional is the only cover.
|
||||
_build_unsloth_torch_overrides
|
||||
run_install_cmd_retry "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
|
||||
${_UNSLOTH_TORCH_OVERRIDES:+--overrides "$_UNSLOTH_TORCH_OVERRIDES"} \
|
||||
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
||||
"unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" ${_MLX_LM_EXCLUDE_ARG:-}
|
||||
"unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3" ${_MLX_LM_EXCLUDE_ARG:-}
|
||||
[ -n "$_UNSLOTH_TORCH_OVERRIDES" ] && rm -f "$_UNSLOTH_TORCH_OVERRIDES"
|
||||
_UNSLOTH_TORCH_OVERRIDES=""
|
||||
fi
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
substep "overlaying local repo (editable)..."
|
||||
|
|
@ -2744,21 +3128,14 @@ if [ "$_MIGRATED" = true ]; then
|
|||
# AMD ROCm: install bitsandbytes even in migrated environments so
|
||||
# existing ROCm installs gain the AMD bitsandbytes build without a
|
||||
# fresh reinstall.
|
||||
if [ "$SKIP_TORCH" = false ]; then
|
||||
case "$TORCH_INDEX_URL" in
|
||||
*/rocm*|*/gfx*)
|
||||
_install_bnb_rocm "install bitsandbytes (AMD)" "$_VENV_PY"
|
||||
# Repair ROCm torch if overwritten during migrated install
|
||||
_has_hip=$("$_VENV_PY" -c "import torch; print(getattr(torch.version,'hip','') or '')" 2>/dev/null || true)
|
||||
if [ -z "$_has_hip" ]; then
|
||||
substep "repairing ROCm torch (overwritten by dependency resolution)..."
|
||||
run_install_cmd_retry "repair ROCm torch" uv pip install --python "$_VENV_PY" \
|
||||
"$TORCH_CONSTRAINT" torchvision torchaudio \
|
||||
--default-index "$TORCH_INDEX_URL" \
|
||||
--force-reinstall
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
if [ "$SKIP_TORCH" = false ] && [ "$_torch_index_is_rocm_family" = true ]; then
|
||||
_install_bnb_rocm "install bitsandbytes (AMD)" "$_VENV_PY"
|
||||
# Repair ROCm torch if overwritten during migrated install
|
||||
_has_hip=$("$_VENV_PY" -c "import torch; print(getattr(torch.version,'hip','') or '')" 2>/dev/null || true)
|
||||
if [ -z "$_has_hip" ]; then
|
||||
substep "repairing ROCm torch (overwritten by dependency resolution)..."
|
||||
_install_torch_default_index --force-reinstall
|
||||
fi
|
||||
fi
|
||||
elif [ -n "$TORCH_INDEX_URL" ]; then
|
||||
# Fresh: Step 1 - install torch from explicit index (skip when --no-torch or Intel Mac)
|
||||
|
|
@ -2820,7 +3197,42 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
_ta_ver=$(_extract_version "$_ta_whl" "torchaudio")
|
||||
|
||||
_radeon_versions_match=false
|
||||
if [ -n "$_torch_ver" ] && [ -n "$_tv_ver" ] && [ -n "$_ta_ver" ]; then
|
||||
# Kept release (_PREV_TORCH_PIN) wins here too: pick its exact
|
||||
# patch (else the newest patch of its minor) plus the paired
|
||||
# vision/audio wheels. Any gap falls back to the newest-trio
|
||||
# search below, mirroring _install_torch_default_index, so a
|
||||
# rerun never drifts to another release nor below the kept one.
|
||||
if [ -n "$_PREV_TORCH_PIN" ]; then
|
||||
_prev_kept_base="${_PREV_TORCH_PIN#torch==}"
|
||||
_prev_kept_minor="${_prev_kept_base#*.}"
|
||||
_prev_kept_minor="${_prev_kept_minor%%.*}"
|
||||
case "$_prev_kept_minor" in
|
||||
''|*[!0-9]*) ;;
|
||||
*)
|
||||
_kept_torch=$(_pick_radeon_wheel "torch" "${_prev_kept_base}" 2>/dev/null) || _kept_torch=""
|
||||
[ -z "$_kept_torch" ] && { _kept_torch=$(_pick_radeon_wheel "torch" "2.${_prev_kept_minor}." 2>/dev/null) || _kept_torch=""; }
|
||||
_kept_tv=$(_pick_radeon_wheel "torchvision" "0.$((_prev_kept_minor + 15))." 2>/dev/null) || _kept_tv=""
|
||||
_kept_ta=$(_pick_radeon_wheel "torchaudio" "2.${_prev_kept_minor}." 2>/dev/null) || _kept_ta=""
|
||||
if [ -n "$_kept_torch" ] && [ -n "$_kept_tv" ] && [ -n "$_kept_ta" ]; then
|
||||
_torch_whl=$_kept_torch
|
||||
_tv_whl=$_kept_tv
|
||||
_ta_whl=$_kept_ta
|
||||
_tri_whl=""
|
||||
_radeon_versions_match=true
|
||||
# Say so when the listing pruned the exact patch
|
||||
# and a same-series build is installed instead.
|
||||
case "$(printf '%s' "${_kept_torch##*/}" | sed 's/%2[Bb]/+/g')" in
|
||||
"torch-${_prev_kept_base}"[+-]*) ;;
|
||||
*) substep "kept release ${_prev_kept_base} is not in the Radeon listing -- installing the closest 2.${_prev_kept_minor} series build instead" ;;
|
||||
esac
|
||||
else
|
||||
substep "[WARN] Radeon repo lacks a complete wheel set for kept $_PREV_TORCH_PIN -- installing the newest compatible set instead" "$C_WARN"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
if [ "$_radeon_versions_match" != true ] && \
|
||||
[ -n "$_torch_ver" ] && [ -n "$_tv_ver" ] && [ -n "$_ta_ver" ]; then
|
||||
_torch_minor=${_torch_ver#*.}
|
||||
_ta_minor=${_ta_ver#*.}
|
||||
_tv_minor=${_tv_ver#*.}
|
||||
|
|
@ -2877,10 +3289,8 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
|
||||
if [ -z "$_torch_whl" ] || [ -z "$_tv_whl" ] || [ -z "$_ta_whl" ] || \
|
||||
[ "$_radeon_versions_match" != true ]; then
|
||||
substep "[WARN] Radeon repo lacks a compatible wheel set for this Python; falling back to ROCm index ($TORCH_INDEX_URL)" "$C_WARN"
|
||||
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" \
|
||||
"$TORCH_CONSTRAINT" torchvision torchaudio \
|
||||
--default-index "$TORCH_INDEX_URL"
|
||||
substep "[WARN] Radeon repo lacks a compatible wheel set for this Python; falling back to ROCm index ($(_strip_index_url_credentials "$TORCH_INDEX_URL"))" "$C_WARN"
|
||||
_install_torch_default_index
|
||||
else
|
||||
substep "installing PyTorch from Radeon repo (${_RADEON_BASE_URL})..."
|
||||
# Pass explicit wheel URLs so the matched trio is
|
||||
|
|
@ -2900,42 +3310,34 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
fi
|
||||
fi
|
||||
else
|
||||
substep "[WARN] Radeon repo unavailable; falling back to ROCm index ($TORCH_INDEX_URL)" "$C_WARN"
|
||||
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" \
|
||||
"$TORCH_CONSTRAINT" torchvision torchaudio \
|
||||
--default-index "$TORCH_INDEX_URL"
|
||||
substep "[WARN] Radeon repo unavailable; falling back to ROCm index ($(_strip_index_url_credentials "$TORCH_INDEX_URL"))" "$C_WARN"
|
||||
_install_torch_default_index
|
||||
fi
|
||||
else
|
||||
substep "[WARN] Radeon GPU detected but could not detect full ROCm version; falling back to ROCm index" "$C_WARN"
|
||||
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" \
|
||||
"$TORCH_CONSTRAINT" torchvision torchaudio \
|
||||
--default-index "$TORCH_INDEX_URL"
|
||||
_install_torch_default_index
|
||||
fi
|
||||
else
|
||||
substep "installing PyTorch ($TORCH_INDEX_URL)..."
|
||||
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" "$TORCH_CONSTRAINT" torchvision torchaudio \
|
||||
--default-index "$TORCH_INDEX_URL"
|
||||
substep "installing PyTorch ($(_strip_index_url_credentials "$TORCH_INDEX_URL"))..."
|
||||
_install_torch_default_index
|
||||
fi
|
||||
# AMD ROCm: install bitsandbytes (once, after torch, for all ROCm paths).
|
||||
# Gate on SKIP_TORCH=false so a user running with --no-torch on a ROCm
|
||||
# host stays in GGUF-only mode rather than pulling in bitsandbytes,
|
||||
# which is only useful once torch is present for training.
|
||||
if [ "$SKIP_TORCH" = false ]; then
|
||||
case "$TORCH_INDEX_URL" in
|
||||
*/rocm*|*/gfx*)
|
||||
_install_bnb_rocm "install bitsandbytes (AMD)" "$_VENV_PY"
|
||||
;;
|
||||
esac
|
||||
if [ "$SKIP_TORCH" = false ] && [ "$_torch_index_is_rocm_family" = true ]; then
|
||||
_install_bnb_rocm "install bitsandbytes (AMD)" "$_VENV_PY"
|
||||
fi
|
||||
# Fresh: Step 2 - install unsloth, preserving pre-installed torch
|
||||
# Fresh: Step 2 - install unsloth, preserving the torch Step 1 installed
|
||||
tauri_log "STEP" "Installing Unsloth"
|
||||
substep "installing unsloth (this may take a few minutes)..."
|
||||
_build_unsloth_torch_overrides
|
||||
if [ "$SKIP_TORCH" = true ]; then
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
run_install_cmd_retry "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
|
||||
--upgrade-package unsloth --upgrade-package unsloth-zoo \
|
||||
"unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2"
|
||||
"unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3"
|
||||
# Same pydantic-with-deps trick as the migrated branch.
|
||||
run_install_cmd_retry "install pydantic (with deps for compatible core)" \
|
||||
uv pip install --python "$_VENV_PY" pydantic
|
||||
|
|
@ -2953,7 +3355,8 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
fi
|
||||
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
run_install_cmd_retry "install unsloth (local)" uv pip install --python "$_VENV_PY" \
|
||||
--upgrade-package unsloth "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2"
|
||||
${_UNSLOTH_TORCH_OVERRIDES:+--overrides "$_UNSLOTH_TORCH_OVERRIDES"} \
|
||||
--upgrade-package unsloth "unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3"
|
||||
substep "overlaying local repo (editable)..."
|
||||
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
||||
substep "overlaying unsloth-zoo from git main..."
|
||||
|
|
@ -2962,30 +3365,26 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
"unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo"
|
||||
else
|
||||
run_install_cmd_retry "install unsloth" uv pip install --python "$_VENV_PY" \
|
||||
${_UNSLOTH_TORCH_OVERRIDES:+--overrides "$_UNSLOTH_TORCH_OVERRIDES"} \
|
||||
--upgrade-package unsloth -- "$PACKAGE_NAME" ${_MLX_LM_EXCLUDE_ARG:-}
|
||||
fi
|
||||
[ -n "$_UNSLOTH_TORCH_OVERRIDES" ] && rm -f "$_UNSLOTH_TORCH_OVERRIDES"
|
||||
_UNSLOTH_TORCH_OVERRIDES=""
|
||||
# AMD ROCm: repair torch if the unsloth/unsloth-zoo install pulled in
|
||||
# CUDA torch from PyPI, overwriting the ROCm wheels installed in Step 1.
|
||||
if [ "$SKIP_TORCH" = false ]; then
|
||||
case "$TORCH_INDEX_URL" in
|
||||
*/rocm*|*/gfx*)
|
||||
_has_hip=$("$_VENV_PY" -c "import torch; print(getattr(torch.version,'hip','') or '')" 2>/dev/null || true)
|
||||
if [ -z "$_has_hip" ]; then
|
||||
substep "repairing ROCm torch (overwritten by dependency resolution)..."
|
||||
run_install_cmd_retry "repair ROCm torch" uv pip install --python "$_VENV_PY" \
|
||||
"$TORCH_CONSTRAINT" torchvision torchaudio \
|
||||
--default-index "$TORCH_INDEX_URL" \
|
||||
--force-reinstall
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
if [ "$SKIP_TORCH" = false ] && [ "$_torch_index_is_rocm_family" = true ]; then
|
||||
_has_hip=$("$_VENV_PY" -c "import torch; print(getattr(torch.version,'hip','') or '')" 2>/dev/null || true)
|
||||
if [ -z "$_has_hip" ]; then
|
||||
substep "repairing ROCm torch (overwritten by dependency resolution)..."
|
||||
_install_torch_default_index --force-reinstall
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# Fallback: GPU detection failed to produce a URL -- let uv resolve torch
|
||||
tauri_log "STEP" "Installing Unsloth"
|
||||
substep "installing unsloth (this may take a few minutes)..."
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.7.2" "unsloth>=2026.7.2" --torch-backend=auto
|
||||
run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.7.3" "unsloth>=2026.7.3" --torch-backend=auto
|
||||
substep "overlaying local repo (editable)..."
|
||||
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
||||
substep "overlaying unsloth-zoo from git main..."
|
||||
|
|
@ -3014,9 +3413,7 @@ if [ "$SKIP_TORCH" = false ] && [ -n "${TORCH_INDEX_URL:-}" ]; then
|
|||
if [ -n "$_installed_torch_tag" ] && [ "$_installed_torch_tag" != "$_expected_torch_tag" ] \
|
||||
&& [ "$(_torch_index_repairable "$TORCH_INDEX_URL")" = "yes" ]; then
|
||||
substep "PyTorch flavor mismatch (installed $_installed_torch_tag, need $_expected_torch_tag) -- reinstalling correct build..."
|
||||
run_install_cmd "reinstall PyTorch ($_expected_torch_tag)" uv pip install --python "$_VENV_PY" \
|
||||
"$TORCH_CONSTRAINT" torchvision torchaudio \
|
||||
--default-index "$TORCH_INDEX_URL" \
|
||||
_install_torch_default_index \
|
||||
--reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio
|
||||
_installed_torch_ver=$("$_VENV_PY" -c "import torch; print(torch.__version__)" 2>/dev/null || true)
|
||||
_installed_torch_tag=""
|
||||
|
|
@ -3027,13 +3424,13 @@ if [ "$SKIP_TORCH" = false ] && [ -n "${TORCH_INDEX_URL:-}" ]; then
|
|||
substep "[WARN] PyTorch is CPU-only but a $_expected_torch_tag GPU build was expected for this machine." "$C_WARN"
|
||||
substep "[WARN] Training and GPU inference will run on CPU until this is fixed." "$C_WARN"
|
||||
substep "[WARN] Re-run this installer, or reinstall the GPU build manually:" "$C_WARN"
|
||||
substep "[WARN] uv pip install --python \"$_VENV_PY\" \"$TORCH_CONSTRAINT\" torchvision torchaudio --default-index $TORCH_INDEX_URL --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio" "$C_WARN"
|
||||
substep "[WARN] uv pip install --python \"$_VENV_PY\" \"$TORCH_CONSTRAINT\" \"$TORCHVISION_CONSTRAINT\" \"$TORCHAUDIO_CONSTRAINT\" --default-index $(_strip_index_url_credentials "$TORCH_INDEX_URL") --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio" "$C_WARN"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Run studio setup ──
|
||||
tauri_log "STEP" "Running Studio setup"
|
||||
tauri_log "STEP" "Running Unsloth setup"
|
||||
# When --local, use the repo's own setup.sh directly.
|
||||
# Otherwise, find it inside the installed package.
|
||||
SETUP_SH=""
|
||||
|
|
@ -3227,7 +3624,7 @@ printf " ${C_TITLE}%s${C_RST}\n" "Unsloth Studio installed!"
|
|||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
echo ""
|
||||
|
||||
# In interactive terminals, ask the user before starting Studio unless the
|
||||
# In interactive terminals, ask the user before starting Unsloth unless the
|
||||
# caller explicitly disabled the post-install prompt.
|
||||
# In non-interactive environments (Docker, CI, cloud-init) just print instructions.
|
||||
if [ "$_SKIP_AUTOSTART" != true ] && [ -t 1 ]; then
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ fi
|
|||
echo "${ROCM_DIR}/lib" | $SUDO tee /etc/ld.so.conf.d/rocm.conf >/dev/null
|
||||
$SUDO ldconfig
|
||||
|
||||
# ── Step 4: persist environment (system-wide so Studio's worker inherits it) ──
|
||||
# ── Step 4: persist environment (system-wide so Unsloth's worker inherits it) ──
|
||||
say "Persisting ROCm-on-WSL environment"
|
||||
_envfile="/etc/profile.d/unsloth-rocm-wsl.sh"
|
||||
$SUDO tee "$_envfile" >/dev/null <<EOF
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
"""Lockfile supply-chain audit for the Studio frontend and Tauri shell.
|
||||
"""Lockfile supply-chain audit for the Unsloth frontend and Tauri shell.
|
||||
|
||||
Runs BEFORE `npm ci` / `cargo fetch` in CI. Refuses to proceed when a
|
||||
lockfile contains patterns indicating supply-chain injection (npm
|
||||
|
|
@ -294,7 +294,7 @@ CARGO_REGISTRY_SOURCE = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
|
||||
# Cargo non-registry source allowlist: `(crate_name, exact_source_string)`.
|
||||
# Both must match verbatim; bumping the pinned SHA forces a re-review.
|
||||
# Studio's Tauri shell pulls `fix-path-env` from git because it is not
|
||||
# Unsloth's Tauri shell pulls `fix-path-env` from git because it is not
|
||||
# published to crates.io; commit c4c45d5 was reviewed when it landed.
|
||||
CARGO_SOURCE_ALLOWLIST: tuple[tuple[str, str], ...] = (
|
||||
(
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
|||
# Hard caps (deliberately conservative; npm tarballs in this repo are
|
||||
# all well under these limits, so a packaging spike is noticeable).
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Caps calibrated against the real Studio frontend transitive closure:
|
||||
# Caps calibrated against the real Unsloth frontend transitive closure:
|
||||
# - typescript.js is 9.1 MB (TS compiler bundled into one file)
|
||||
# - mermaid 11.x dist/mermaid.js.map is ~12 MB (sourcemap)
|
||||
# - lightningcss-linux-x64-{gnu,musl}.node is 10 MB
|
||||
|
|
|
|||
|
|
@ -95,8 +95,8 @@
|
|||
"file": "fastapi/routing.py",
|
||||
"check": "C2 polling/beaconing loop detected",
|
||||
"severity": "CRITICAL",
|
||||
"evidence": "L586: while True: sha256:251135b5ebfdd1248916449f32262575e003ef64382501c65b7e4061d67bda45",
|
||||
"evidence_hash": "365aef4449c8089753d9398417cd76ab762cef547d75db70d87bca9c0b550ab5"
|
||||
"evidence": "L587: while True: sha256:06c2c7f15d73bf192e5e3272c5ff5fcaeff7f6774fef5f4eca6ef473ae50e2b3",
|
||||
"evidence_hash": "57acd497f404c203e4450d0580ad85aa8a33406e8d64ad06fbac6cf47d97b24d"
|
||||
},
|
||||
{
|
||||
"package": "fastmcp-slim",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Stamp and verify display-only Studio release metadata for builds."""
|
||||
"""Stamp and verify display-only Unsloth release metadata for builds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ MAX_VERSION_LENGTH = 64
|
|||
PLACEHOLDER = """# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
\"\"\"Build-stamped Studio release metadata.
|
||||
\"\"\"Build-stamped Unsloth release metadata.
|
||||
|
||||
Release builds may rewrite this module in the build workspace before creating
|
||||
Python artifacts. Keep the committed value neutral so source checkouts do not
|
||||
|
|
@ -145,7 +145,7 @@ def build_info_source(version: str | None) -> str:
|
|||
return f'''# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Build-stamped Studio release metadata."""
|
||||
"""Build-stamped Unsloth release metadata."""
|
||||
|
||||
STUDIO_RELEASE_VERSION = {literal}
|
||||
'''
|
||||
|
|
@ -168,7 +168,7 @@ def stamp(require_release: bool) -> int:
|
|||
version, source = resolve_version()
|
||||
if version is not None and not is_valid_version(version):
|
||||
print(
|
||||
f"Invalid Studio release version from {source}: {version!r}",
|
||||
f"Invalid Unsloth release version from {source}: {version!r}",
|
||||
file = sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
|
@ -196,9 +196,9 @@ def stamp(require_release: bool) -> int:
|
|||
if version is None:
|
||||
if require_release:
|
||||
print(
|
||||
"No Studio release version available. Set "
|
||||
"No Unsloth release version available. Set "
|
||||
"UNSLOTH_STUDIO_RELEASE_VERSION, build from a GitHub tag, "
|
||||
"or run from an exact local Studio release tag.",
|
||||
"or run from an exact local Unsloth release tag.",
|
||||
file = sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
|
@ -207,7 +207,7 @@ def stamp(require_release: bool) -> int:
|
|||
return 0
|
||||
|
||||
_atomic_write_text(BUILD_INFO_PATH, build_info_source(version), encoding = "utf-8")
|
||||
print(f"Stamping Studio release version {version} from {source}", file = sys.stderr)
|
||||
print(f"Stamping Unsloth release version {version} from {source}", file = sys.stderr)
|
||||
print(version)
|
||||
return 0
|
||||
|
||||
|
|
@ -233,7 +233,7 @@ def _read_sdist_member(path: Path) -> str | None:
|
|||
|
||||
def verify_dist(expected: str, dist_dir: Path) -> int:
|
||||
if not is_valid_version(expected):
|
||||
print(f"Invalid expected Studio release version: {expected!r}", file = sys.stderr)
|
||||
print(f"Invalid expected Unsloth release version: {expected!r}", file = sys.stderr)
|
||||
return 2
|
||||
|
||||
artifacts = list(dist_dir.glob("*.whl")) + list(dist_dir.glob("*.tar.gz"))
|
||||
|
|
@ -251,14 +251,14 @@ def verify_dist(expected: str, dist_dir: Path) -> int:
|
|||
if content is None:
|
||||
failures.append(f"{artifact.name}: missing {BUILD_INFO_SUFFIX}")
|
||||
elif expected_line not in content:
|
||||
failures.append(f"{artifact.name}: Studio release version mismatch")
|
||||
failures.append(f"{artifact.name}: Unsloth release version mismatch")
|
||||
|
||||
if failures:
|
||||
for failure in failures:
|
||||
print(failure, file = sys.stderr)
|
||||
return 2
|
||||
|
||||
print(f"Verified Studio release version {expected} in {len(artifacts)} artifact(s)")
|
||||
print(f"Verified Unsloth release version {expected} in {len(artifacts)} artifact(s)")
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ function Uninstall-UnslothStudio {
|
|||
}
|
||||
}
|
||||
|
||||
# A path is a Studio-owned root iff one of install.ps1's sentinels exists:
|
||||
# A path is an Unsloth-owned root iff one of install.ps1's sentinels exists:
|
||||
# <root>\share\studio.conf, <root>\unsloth_studio\.unsloth-studio-owned,
|
||||
# or <root>\bin\unsloth.exe.
|
||||
function _IsStudioRoot {
|
||||
|
|
@ -164,7 +164,7 @@ function Uninstall-UnslothStudio {
|
|||
return $p
|
||||
}
|
||||
|
||||
# Discover non-default Studio roots from env vars + studio.conf files.
|
||||
# Discover non-default Unsloth roots from env vars + studio.conf files.
|
||||
# Mirrors install.ps1's precedence: UNSLOTH_STUDIO_HOME wins, STUDIO_HOME
|
||||
# is ignored when both are set, so uninstalling install A doesn't also
|
||||
# delete install B if the user has a stale STUDIO_HOME pointing at B.
|
||||
|
|
@ -207,7 +207,7 @@ function Uninstall-UnslothStudio {
|
|||
|
||||
# Return $true iff the PID's image path lives under one of $KnownRoots.
|
||||
# Prevents killing an unrelated process that happens to listen on a stale
|
||||
# Studio port.
|
||||
# Unsloth port.
|
||||
function _PidUnderKnownRoot {
|
||||
param([int]$Pid_, [string[]]$KnownRoots)
|
||||
if (-not $KnownRoots -or $KnownRoots.Count -eq 0) { return $false }
|
||||
|
|
@ -223,8 +223,8 @@ function Uninstall-UnslothStudio {
|
|||
return $false
|
||||
}
|
||||
|
||||
# Stop a Studio backend whose port is recorded in <DataDir>\studio.port.
|
||||
# Only kills if the listening PID's exe path is under a known Studio root.
|
||||
# Stop an Unsloth backend whose port is recorded in <DataDir>\studio.port.
|
||||
# Only kills if the listening PID's exe path is under a known Unsloth root.
|
||||
function _StopByPortFile {
|
||||
param([string]$PortFile, [string[]]$KnownRoots)
|
||||
if (-not (Test-Path -LiteralPath $PortFile -PathType Leaf)) { return }
|
||||
|
|
@ -372,7 +372,7 @@ function Uninstall-UnslothStudio {
|
|||
continue
|
||||
}
|
||||
if (-not (_IsStudioRoot $r)) {
|
||||
_Substep "refusing to remove non-Studio path: $r" "Yellow"
|
||||
_Substep "refusing to remove non-Unsloth path: $r" "Yellow"
|
||||
continue
|
||||
}
|
||||
_RemovePath $r
|
||||
|
|
@ -436,7 +436,7 @@ function Uninstall-UnslothStudio {
|
|||
$entries = $rawPath -split ';'
|
||||
$kept = New-Object System.Collections.ArrayList
|
||||
$removedAny = $false
|
||||
# Only remove PATH entries that live inside a Studio root we
|
||||
# Only remove PATH entries that live inside an Unsloth root we
|
||||
# actually own (default or env-mode). A literal substring
|
||||
# match on `unsloth_studio` would clobber unrelated user
|
||||
# virtualenvs that happen to share the name.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
set -e
|
||||
|
||||
# Stop a Studio server via its PID file (written by install.sh's _spawn_terminal).
|
||||
# Stop an Unsloth server via its PID file (written by install.sh's _spawn_terminal).
|
||||
_kill_pid_file() {
|
||||
_pid_file="$1"
|
||||
[ -f "$_pid_file" ] || return 0
|
||||
|
|
@ -47,7 +47,7 @@ _pkill_studio() {
|
|||
command -v pkill >/dev/null 2>&1 || return 0
|
||||
|
||||
# Scope fallback patterns to the install roots we are removing so a
|
||||
# different Studio install (different UNSLOTH_STUDIO_HOME) is not touched.
|
||||
# different Unsloth install (different UNSLOTH_STUDIO_HOME) is not touched.
|
||||
_kill_roots="$HOME/.unsloth/studio"
|
||||
_roots_from_conf=$(_custom_studio_roots 2>/dev/null || true)
|
||||
[ -n "$_roots_from_conf" ] && _kill_roots="$_kill_roots
|
||||
|
|
@ -89,7 +89,7 @@ _remove_path() {
|
|||
fi
|
||||
}
|
||||
|
||||
# Accept as Studio root only if Studio sentinels exist (matches install.sh's
|
||||
# Accept as Unsloth root only if Unsloth sentinels exist (matches install.sh's
|
||||
# env-mode ownership guard at install.sh:1358-1361). A bare unsloth_studio/
|
||||
# directory is NOT enough -- require the install-time owner marker so a user
|
||||
# directory that happens to contain a folder named "unsloth_studio" is safe.
|
||||
|
|
@ -175,8 +175,8 @@ _custom_studio_roots() {
|
|||
_from_conf "$HOME/.local/share/unsloth/studio.conf"
|
||||
}
|
||||
|
||||
# Remove $HOME/.local/bin/unsloth only if it's a Studio-managed symlink.
|
||||
# Studio's install.sh writes this as a symlink into the studio venv
|
||||
# Remove $HOME/.local/bin/unsloth only if it's an Unsloth-managed symlink.
|
||||
# Unsloth's install.sh writes this as a symlink into the studio venv
|
||||
# (install.sh: `ln -sfn "$VENV_DIR/bin/unsloth" "$_shim_path"`). A
|
||||
# pip-installed `unsloth` CLI is a regular file — leave it alone to avoid
|
||||
# wiping an unrelated install.
|
||||
|
|
@ -206,7 +206,7 @@ _custom_studio_roots | while IFS= read -r _custom_root; do
|
|||
continue
|
||||
fi
|
||||
if ! _is_studio_root "$_custom_root"; then
|
||||
echo " refusing to remove non-Studio path: $_custom_root" >&2
|
||||
echo " refusing to remove non-Unsloth path: $_custom_root" >&2
|
||||
continue
|
||||
fi
|
||||
_remove_path "$_custom_root"
|
||||
|
|
@ -234,7 +234,7 @@ _remove_path "$HOME/.unsloth/rocm-smoketest"
|
|||
# Drop ~/.unsloth only if now empty (rmdir refuses non-empty, so user content is kept).
|
||||
rmdir "$HOME/.unsloth" 2>/dev/null || true
|
||||
_remove_path "$HOME/.local/share/unsloth"
|
||||
# CLI shim: only the symlink Studio created, never a pip-installed file.
|
||||
# CLI shim: only the symlink Unsloth created, never a pip-installed file.
|
||||
_remove_cli_shim
|
||||
|
||||
echo "Removing desktop shortcut and launcher lock..."
|
||||
|
|
|
|||
34
studio/MCP.md
Normal file
34
studio/MCP.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Unsloth Studio MCP server
|
||||
|
||||
Unsloth can expose a local MCP server so an MCP client can inspect models and
|
||||
GPU state, validate recipes, start or stop training, inspect recipe output, and
|
||||
export a loaded model.
|
||||
|
||||
The server is disabled by default. Enable it for a local Unsloth process with:
|
||||
|
||||
```bash
|
||||
UNSLOTH_STUDIO_ENABLE_MCP=1 \
|
||||
UNSLOTH_STUDIO_MCP_TOKEN='use-a-local-secret' \
|
||||
unsloth studio
|
||||
```
|
||||
|
||||
The endpoint is `http://127.0.0.1:8888/mcp/` when Unsloth uses its default port
|
||||
(a request to `/mcp` redirects to the canonical `/mcp/`). Use the actual Unsloth
|
||||
port when it is configured differently.
|
||||
|
||||
The high-impact tools are:
|
||||
|
||||
- `studio_status` and `list_local_models` for discovery
|
||||
- `get_training_status`, `start_training`, `stop_training`, and `list_training_runs`
|
||||
- `validate_recipe`, `get_recipe_job_status`, and `get_recipe_job_dataset`
|
||||
- `load_checkpoint` and `export_gguf`
|
||||
|
||||
`start_training` accepts the same fields as the Unsloth `TrainingStartRequest`.
|
||||
The request is validated by the existing Pydantic model before a subprocess is
|
||||
started. Export paths use the existing Unsloth validation as well.
|
||||
|
||||
The endpoint always requires `UNSLOTH_STUDIO_MCP_TOKEN` and checks an exact
|
||||
Bearer token for both HTTP and WebSocket connections. Keep it on localhost
|
||||
unless the deployment has an authenticated reverse proxy. The MCP endpoint is
|
||||
intentionally opt-in because tools can consume GPU memory, write model
|
||||
artifacts, and stop active work.
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
"\n",
|
||||
"We are actively working on making Unsloth Studio install on Colab T4 GPUs faster.\n",
|
||||
"\n",
|
||||
"[Features](https://unsloth.ai/docs/new/unsloth-studio#features) • [Quickstart](https://unsloth.ai/docs/new/unsloth-studio/start) • [Data Recipes](https://unsloth.ai/docs/new/unsloth-studio/data-recipe) • [Studio Chat](https://unsloth.ai/docs/new/unsloth-studio/chat) • [Export](https://unsloth.ai/docs/new/unsloth-studio/export)"
|
||||
"[Features](https://unsloth.ai/docs/new/unsloth-studio#features) • [Quickstart](https://unsloth.ai/docs/new/unsloth-studio/start) • [Data Recipes](https://unsloth.ai/docs/new/unsloth-studio/data-recipe) • [Unsloth Chat](https://unsloth.ai/docs/new/unsloth-studio/chat) • [Export](https://unsloth.ai/docs/new/unsloth-studio/export)"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Source: google/gemma-4-31B-it HF discussion/PR #118 (adds the preserve_thinking
|
||||
flag plus null-rendering, string-arguments validation, balanced turn tags, empty
|
||||
messages handling, and OpenAI image_url/input_audio aliases).
|
||||
Studio-local changes vs PR #118:
|
||||
Unsloth-local changes vs PR #118:
|
||||
1. preserve_thinking defaults to false (see SETUP block below).
|
||||
2. The empty "<|channel>thought\n<channel|>" block on enable_thinking=false is
|
||||
NOT emitted. Google ships a distinct template for E2B/E4B (google/gemma-4-E2B-it,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Source: google/gemma-4-31B-it HF discussion/PR #118 (adds the preserve_thinking
|
||||
flag plus null-rendering, string-arguments validation, balanced turn tags, empty
|
||||
messages handling, and OpenAI image_url/input_audio aliases).
|
||||
Studio-local change: preserve_thinking defaults to false (see SETUP block below).
|
||||
Unsloth-local change: preserve_thinking defaults to false (see SETUP block below).
|
||||
Applied to unsloth/gemma-4-*-GGUF models so the embedded GGUF template does not
|
||||
need re-downloading. Keep in sync with upstream if PR #118 changes.
|
||||
-#}
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ async def authenticated_via_api_key(
|
|||
) -> bool:
|
||||
"""True when the caller used an sk-unsloth API key, not a UI session JWT.
|
||||
|
||||
Lets routes treat programmatic API callers differently from the Studio UI
|
||||
Lets routes treat programmatic API callers differently from the Unsloth UI
|
||||
(e.g. refuse a teardown the UI would allow).
|
||||
"""
|
||||
return bool(credentials and credentials.credentials.startswith(API_KEY_PREFIX))
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Auto-shutdown for an exposed first-run Studio whose admin password is unchanged.
|
||||
"""Auto-shutdown for an exposed first-run Unsloth whose admin password is unchanged.
|
||||
|
||||
On a fresh install the seeded bootstrap admin password stays a valid login
|
||||
credential until first login changes it. When the web UI is put on the network
|
||||
(``--secure`` / ``0.0.0.0``) and nobody completes that first-login change within
|
||||
a deadline, tear Studio down so a fresh, unconfigured instance does not stay
|
||||
publicly reachable indefinitely. If the password was changed, Studio keeps
|
||||
a deadline, tear Unsloth down so a fresh, unconfigured instance does not stay
|
||||
publicly reachable indefinitely. If the password was changed, Unsloth keeps
|
||||
running.
|
||||
|
||||
Scope: web UI launches only (never ``--api-only``, which authenticates by API
|
||||
|
|
@ -98,7 +98,7 @@ def enforce_bootstrap_password_deadline(
|
|||
) -> bool:
|
||||
"""Deadline handler: shut down iff the seeded admin password is still unchanged.
|
||||
|
||||
Returns True if it shut Studio down, False if it left it running (the
|
||||
Returns True if it shut Unsloth down, False if it left it running (the
|
||||
password was changed in time).
|
||||
"""
|
||||
try:
|
||||
|
|
@ -106,7 +106,7 @@ def enforce_bootstrap_password_deadline(
|
|||
except Exception:
|
||||
return False
|
||||
if not still_default:
|
||||
return False # password changed in time -> leave Studio running
|
||||
return False # password changed in time -> leave Unsloth running
|
||||
|
||||
message = (
|
||||
"\nUnsloth Studio was exposed on the network but its default admin "
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ def get_connection() -> sqlite3.Connection:
|
|||
pass
|
||||
conn.row_factory = sqlite3.Row
|
||||
# WAL lets token reads run concurrently with refresh-token writes;
|
||||
# busy_timeout bounds lock waits. Matches the other Studio SQLite stores.
|
||||
# busy_timeout bounds lock waits. Matches the other Unsloth SQLite stores.
|
||||
# Set busy_timeout first: switching journal_mode needs a lock, so if a
|
||||
# refresh-token write already holds one, journal_mode=WAL raises SQLITE_BUSY;
|
||||
# with busy_timeout already in effect it waits instead of failing and leaving
|
||||
|
|
@ -305,8 +305,8 @@ def get_or_create_identity_secret() -> bytes:
|
|||
def compute_identity_proof(nonce: bytes, host: str, port: int) -> str:
|
||||
"""HMAC-SHA256 proof that the caller holds this install's identity secret,
|
||||
bound to the loopback address and port the connection landed on. A proof
|
||||
relayed from a Studio on a different address/port (a squatter proxying to the
|
||||
real one, e.g. localhost resolving to ::1 while Studio is on 127.0.0.1) was
|
||||
relayed from an Unsloth on a different address/port (a squatter proxying to the
|
||||
real one, e.g. localhost resolving to ::1 while Unsloth is on 127.0.0.1) was
|
||||
computed for that other endpoint and won't match the one the client dialed."""
|
||||
try:
|
||||
host = ipaddress.ip_address(host).compressed # normalise 127.0.0.1 / ::1 forms
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Interactive terminal prompt that forces a bootstrap password change before
|
||||
Studio is exposed on a public Cloudflare URL (``--secure`` / ``--cloudflare``).
|
||||
Unsloth is exposed on a public Cloudflare URL (``--secure`` / ``--cloudflare``).
|
||||
|
||||
Masked input echoes one ``*`` per keystroke (unlike ``getpass``). Works on
|
||||
Windows (``msvcrt``) and Linux/macOS (``termios``). All output goes to stderr so
|
||||
redirected stdout never swallows the prompt.
|
||||
|
||||
Mirrored for the CLI at ``unsloth_cli/commands/_password_prompt.py`` (the CLI
|
||||
cannot import the Studio backend package); keep the two in sync.
|
||||
cannot import the Unsloth backend package); keep the two in sync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -252,7 +252,7 @@ def prompt_for_password_change(
|
|||
out.flush()
|
||||
return True
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
out.write("Password change aborted; not exposing Studio.\n")
|
||||
out.write("Password change aborted; not exposing Unsloth.\n")
|
||||
out.flush()
|
||||
return False
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Free Cloudflare quick tunnel for Studio's 0.0.0.0 launches.
|
||||
"""Free Cloudflare quick tunnel for Unsloth's 0.0.0.0 launches.
|
||||
|
||||
The raw http://<ip>:<port> is often unreachable (https-vs-http, blocked ports,
|
||||
closed security groups); a cloudflared quick tunnel gives a free
|
||||
https://*.trycloudflare.com URL that works anywhere, with no account or domain.
|
||||
|
||||
Best-effort throughout: any failure collapses to "no URL" and Studio keeps
|
||||
Best-effort throughout: any failure collapses to "no URL" and Unsloth keeps
|
||||
running. Stdlib only (back-end imports are lazy) so it is safe to import early.
|
||||
"""
|
||||
|
||||
|
|
@ -95,7 +95,7 @@ def _cache_path() -> Optional[Path]:
|
|||
|
||||
|
||||
def find_cloudflared() -> Optional[str]:
|
||||
"""Locate an existing cloudflared: PATH first, then the Studio bin cache."""
|
||||
"""Locate an existing cloudflared: PATH first, then the Unsloth bin cache."""
|
||||
on_path = shutil.which("cloudflared")
|
||||
if on_path:
|
||||
return on_path
|
||||
|
|
@ -309,7 +309,7 @@ class CloudflareTunnel:
|
|||
pass
|
||||
|
||||
|
||||
# Single serving process per Studio launch, so one module-level tunnel handle is
|
||||
# Single serving process per Unsloth launch, so one module-level tunnel handle is
|
||||
# enough; the lock guards the start/stop/shutdown races.
|
||||
_active_tunnel: Optional[CloudflareTunnel] = None
|
||||
_active_lock = threading.Lock()
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ def start_cloudflare_tunnel(port: int) -> "str | None":
|
|||
logger.warning(
|
||||
"Cloudflare link not started: the admin account still has its temporary "
|
||||
"bootstrap password, which is exposed to anyone who can load the page. "
|
||||
"Open Studio in this tab, log in and change the admin password, then re-run "
|
||||
"Open Unsloth in this tab, log in and change the admin password, then re-run "
|
||||
"start(cloudflare=True) to get the shareable link."
|
||||
)
|
||||
return None
|
||||
|
|
@ -203,7 +203,7 @@ def _shareable_link_html(cloudflare_url: str) -> str:
|
|||
display: flex; align-items: center; gap: 12px;">
|
||||
<img src="https://github.com/unslothai/unsloth/raw/main/studio/frontend/public/unsloth-gem.png"
|
||||
height="48" style="display:block;">
|
||||
Shareable Studio Link is Ready!
|
||||
Shareable Unsloth Link is Ready!
|
||||
</h2>
|
||||
<a href="{cloudflare_url}" onclick="var w=window.open(this.href,'_blank');if(!w){{return true;}}return false;"
|
||||
style="display: inline-flex; align-items: center; gap: 10px; padding: 14px 28px;
|
||||
|
|
@ -223,7 +223,7 @@ def _shareable_link_html(cloudflare_url: str) -> str:
|
|||
|
||||
|
||||
def _show_and_embed(port: int, *, cloudflare_url: "str | None" = None):
|
||||
"""Render the Studio header + iframe for *port*, with a shareable-link card above
|
||||
"""Render the Unsloth header + iframe for *port*, with a shareable-link card above
|
||||
when *cloudflare_url* is set. Falls back to serve_kernel_port_as_iframe."""
|
||||
url = get_colab_url(port)
|
||||
logger.info(f"🌐 Unsloth Studio URL: {url}")
|
||||
|
|
@ -281,7 +281,7 @@ def start(port: int = 8888, *, cloudflare: bool = False):
|
|||
Args:
|
||||
port: Port to bind/serve on.
|
||||
cloudflare: Opt in to a shareable Cloudflare HTTPS link reachable from any
|
||||
device (default OFF). It exposes Studio's login page beyond Colab, so it
|
||||
device (default OFF). It exposes Unsloth's login page beyond Colab, so it
|
||||
stays an explicit opt-in; the default shows only the in-tab proxy iframe.
|
||||
|
||||
Usage:
|
||||
|
|
@ -292,10 +292,10 @@ def start(port: int = 8888, *, cloudflare: bool = False):
|
|||
|
||||
logger.info("🦥 Starting Unsloth Studio...")
|
||||
|
||||
# Fast path: Studio already running (cell re-run). Re-launching would collide on
|
||||
# Fast path: Unsloth already running (cell re-run). Re-launching would collide on
|
||||
# the port, so just re-show the link and iframe.
|
||||
if _is_studio_healthy(port):
|
||||
logger.info(f" Studio is already running on port {port} — reusing existing server.")
|
||||
logger.info(f" Unsloth is already running on port {port} — reusing existing server.")
|
||||
# try/finally: tear the tunnel down even if interrupted mid-start/render.
|
||||
try:
|
||||
cf_url = start_cloudflare_tunnel(port) if cloudflare else None
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ def parse_log_message(msg: str) -> ParsedUpdate | None:
|
|||
source = "github",
|
||||
status = "rate_limited",
|
||||
retry_after_sec = seconds,
|
||||
message = ("Waiting for GitHub rate limit. Studio will resume automatically."),
|
||||
message = ("Waiting for GitHub rate limit. Unsloth will resume automatically."),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -147,7 +147,7 @@ def parse_log_message(msg: str) -> ParsedUpdate | None:
|
|||
status = "rate_limited",
|
||||
retry_after_sec = seconds,
|
||||
message = (
|
||||
"Waiting for GitHub secondary rate limit. Studio will resume automatically."
|
||||
"Waiting for GitHub secondary rate limit. Unsloth will resume automatically."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -161,7 +161,7 @@ def parse_log_message(msg: str) -> ParsedUpdate | None:
|
|||
source = "github",
|
||||
status = "rate_limited",
|
||||
retry_after_sec = seconds,
|
||||
message = ("Waiting for GitHub rate limit. Studio will resume automatically."),
|
||||
message = ("Waiting for GitHub rate limit. Unsloth will resume automatically."),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@ def _run_oxc_batch(
|
|||
if not node_executable:
|
||||
return _fallback_results(
|
||||
len(code_values),
|
||||
"Node.js not found (install Node >= 20.19, or re-run Studio setup to provision it).",
|
||||
"Node.js not found (install Node >= 20.19, or re-run Unsloth setup to provision it).",
|
||||
)
|
||||
try:
|
||||
tmp_dir = ensure_dir(oxc_validator_tmp_root())
|
||||
|
|
|
|||
|
|
@ -280,8 +280,8 @@ def create_data_designer(recipe: dict[str, Any], *, artifact_path: str | None =
|
|||
from data_designer.interface.data_designer import DataDesigner # pyright: ignore[reportMissingImports]
|
||||
|
||||
if artifact_path is None:
|
||||
# DataDesigner defaults to cwd/artifacts; packaged Studio can run with
|
||||
# cwd=/, so keep default callers on Studio's writable recipe artifact root.
|
||||
# DataDesigner defaults to cwd/artifacts; packaged Unsloth can run with
|
||||
# cwd=/, so keep default callers on Unsloth's writable recipe artifact root.
|
||||
artifact_path = str(recipe_datasets_root())
|
||||
|
||||
recipe = _strip_frontend_model_config_metadata(recipe)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ subprocess and can be imported directly from .inference when needed.
|
|||
Public names are resolved lazily (PEP 562): importing this package -- or a
|
||||
dependency-light leaf like ``core.inference.chat_eos`` -- must NOT eagerly pull
|
||||
the orchestrator / llama_cpp import chain (httpx, subprocess plumbing, the ML
|
||||
backend and its Studio dependencies). Those load only when a public name is
|
||||
backend and its Unsloth dependencies). Those load only when a public name is
|
||||
actually accessed, so standalone helpers stay unit-testable without the full
|
||||
inference stack.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -539,7 +539,7 @@ class AnthropicPassthroughEmitter:
|
|||
|
||||
Only calls naming a tool in ``allowed_tools`` (the client's declared
|
||||
tools) are promoted; everything else streams as text exactly as before.
|
||||
Never enabled for Studio's own tool loop.
|
||||
Never enabled for Unsloth's own tool loop.
|
||||
"""
|
||||
from core.inference.passthrough_healing import StreamToolCallHealer
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,242 @@ native-chat-template fallback used by the transformers and MLX backends.
|
|||
import copy
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
_THINK_OPEN = "<think>"
|
||||
_THINK_CLOSE = "</think>"
|
||||
_GEMMA_CHANNEL_START = "<|channel>"
|
||||
_GEMMA_THOUGHT_OPEN = "<|channel>thought"
|
||||
_GEMMA_THOUGHT_CLOSE = "<channel|>"
|
||||
_GEMMA_TEMPLATE_OPENERS = (
|
||||
_GEMMA_THOUGHT_OPEN + "\n",
|
||||
_GEMMA_THOUGHT_OPEN + "\\n",
|
||||
_GEMMA_THOUGHT_OPEN + _GEMMA_THOUGHT_CLOSE,
|
||||
)
|
||||
|
||||
|
||||
def _tokenizer_objects(tokenizer) -> tuple:
|
||||
"""Return a processor/tokenizer and its distinct nested tokenizer."""
|
||||
if tokenizer is None:
|
||||
return ()
|
||||
nested = getattr(tokenizer, "tokenizer", None)
|
||||
return (tokenizer,) if nested is None or nested is tokenizer else (tokenizer, nested)
|
||||
|
||||
|
||||
def _selected_template_strings_from_value(
|
||||
template,
|
||||
tools = None,
|
||||
*,
|
||||
prefer_tool_use: bool = True,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return the named chat template matching HF's default selection rules."""
|
||||
tools = tools or None
|
||||
if isinstance(template, str):
|
||||
return (template,)
|
||||
if not isinstance(template, dict):
|
||||
return ()
|
||||
if prefer_tool_use and tools and isinstance(template.get("tool_use"), str):
|
||||
return (template["tool_use"],)
|
||||
if isinstance(template.get("default"), str):
|
||||
return (template["default"],)
|
||||
values = tuple(value for value in template.values() if isinstance(value, str))
|
||||
return values if len(values) == 1 else ()
|
||||
|
||||
|
||||
def _selected_chat_template_strings(tokenizer, tools = None) -> tuple[str, ...]:
|
||||
"""Return the active chat template selected for this request."""
|
||||
tools = tools or None
|
||||
getter = getattr(tokenizer, "get_chat_template", None)
|
||||
if callable(getter):
|
||||
for kwargs in ({"chat_template": None, "tools": tools}, {"tools": tools}, {}):
|
||||
try:
|
||||
selected = getter(**kwargs)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(selected, str):
|
||||
return (selected,)
|
||||
# ProcessorMixin.apply_chat_template does not switch to "tool_use" implicitly;
|
||||
# it uses "default" unless chat_template= names another template.
|
||||
is_processor = getattr(tokenizer, "tokenizer", None) is not None and callable(
|
||||
getattr(tokenizer, "apply_chat_template", None)
|
||||
)
|
||||
return _selected_template_strings_from_value(
|
||||
getattr(tokenizer, "chat_template", None),
|
||||
tools,
|
||||
prefer_tool_use = not is_processor,
|
||||
)
|
||||
|
||||
|
||||
def _detect_reasoning_channel_markers_from_templates(
|
||||
templates: tuple[str, ...],
|
||||
) -> Optional[tuple[str, str]]:
|
||||
"""Return Gemma native reasoning markers only when a template emits them."""
|
||||
if any(opener in template for template in templates for opener in _GEMMA_TEMPLATE_OPENERS):
|
||||
return _GEMMA_THOUGHT_OPEN, _GEMMA_THOUGHT_CLOSE
|
||||
return None
|
||||
|
||||
|
||||
def detect_reasoning_channel_markers(tokenizer, tools = None) -> Optional[tuple[str, str]]:
|
||||
"""Return native Gemma thought-channel markers supported by a tokenizer.
|
||||
|
||||
Detection uses the active chat template rather than model names or vocabulary
|
||||
membership. Some models expose Gemma control tokens without using the native
|
||||
thought-channel response protocol, and those must keep normal
|
||||
``skip_special_tokens`` streaming.
|
||||
"""
|
||||
for obj in _tokenizer_objects(tokenizer):
|
||||
templates = _selected_chat_template_strings(obj, tools)
|
||||
if templates:
|
||||
return _detect_reasoning_channel_markers_from_templates(templates)
|
||||
return None
|
||||
|
||||
|
||||
def detect_reasoning_channel_markers_from_template(
|
||||
template, tools = None
|
||||
) -> Optional[tuple[str, str]]:
|
||||
"""Return native Gemma thought-channel markers from a raw template value."""
|
||||
return _detect_reasoning_channel_markers_from_templates(
|
||||
_selected_template_strings_from_value(template, tools)
|
||||
)
|
||||
|
||||
|
||||
def detect_reasoning_channel_markers_from_model_info(
|
||||
tokenizer,
|
||||
model_info: Optional[dict] = None,
|
||||
tools = None,
|
||||
) -> Optional[tuple[str, str]]:
|
||||
"""Return reasoning markers from the active or cached native template."""
|
||||
markers = detect_reasoning_channel_markers(tokenizer, tools = tools)
|
||||
if markers is not None or not isinstance(model_info, dict):
|
||||
return markers
|
||||
|
||||
native_templates = (
|
||||
model_info.get("native_chat_template"),
|
||||
(model_info.get("chat_template_info") or {}).get("template"),
|
||||
)
|
||||
for template in native_templates:
|
||||
markers = detect_reasoning_channel_markers_from_template(template, tools)
|
||||
if markers is not None:
|
||||
return markers
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen = True)
|
||||
class ChatTemplateRenderResult:
|
||||
"""Prompt plus response-protocol metadata selected by the renderer."""
|
||||
|
||||
prompt: str
|
||||
reasoning_channel_markers: Optional[tuple[str, str]] = None
|
||||
|
||||
|
||||
def _split_partial_marker(text: str, marker: str) -> tuple[str, str]:
|
||||
"""Hold the longest suffix that may become ``marker`` in the next chunk."""
|
||||
for length in range(min(len(text), len(marker) - 1), 0, -1):
|
||||
if text.endswith(marker[:length]):
|
||||
return text[:-length], text[-length:]
|
||||
return text, ""
|
||||
|
||||
|
||||
class ReasoningChannelNormalizer:
|
||||
"""Incrementally convert one native reasoning channel to ``<think>``.
|
||||
|
||||
The parser follows mlx-vlm's streaming boundary behavior but emits Unsloth's
|
||||
established canonical text contract. Only the configured opening and
|
||||
closing markers are consumed; tool-call and other control markers remain
|
||||
available to downstream parsers.
|
||||
"""
|
||||
|
||||
def __init__(self, opening_marker: str, closing_marker: str):
|
||||
self._opening_marker = opening_marker
|
||||
self._closing_marker = closing_marker
|
||||
self._buffer = ""
|
||||
self._in_reasoning = False
|
||||
self._reasoning_done = False
|
||||
self._skip_opening_newline = False
|
||||
|
||||
def feed(self, text: str) -> str:
|
||||
"""Consume a raw text delta and return the stable canonical delta."""
|
||||
self._buffer += text or ""
|
||||
output: list[str] = []
|
||||
while self._buffer:
|
||||
if self._reasoning_done:
|
||||
output.append(self._buffer)
|
||||
self._buffer = ""
|
||||
break
|
||||
|
||||
if self._in_reasoning and self._skip_opening_newline:
|
||||
if self._buffer.startswith("\n"):
|
||||
self._buffer = self._buffer[1:]
|
||||
self._skip_opening_newline = False
|
||||
if not self._buffer:
|
||||
break
|
||||
|
||||
marker = self._closing_marker if self._in_reasoning else self._opening_marker
|
||||
index = self._buffer.find(marker)
|
||||
if index < 0:
|
||||
stable, self._buffer = _split_partial_marker(self._buffer, marker)
|
||||
output.append(stable)
|
||||
break
|
||||
|
||||
output.append(self._buffer[:index])
|
||||
self._buffer = self._buffer[index + len(marker) :]
|
||||
if self._in_reasoning:
|
||||
output.append(_THINK_CLOSE)
|
||||
self._in_reasoning = False
|
||||
self._reasoning_done = True
|
||||
else:
|
||||
output.append(_THINK_OPEN)
|
||||
self._in_reasoning = True
|
||||
self._skip_opening_newline = True
|
||||
return "".join(output)
|
||||
|
||||
def finish(self) -> str:
|
||||
"""Flush a naturally completed stream and close an open think block."""
|
||||
output = self.drain()
|
||||
if self._in_reasoning:
|
||||
output += _THINK_CLOSE
|
||||
self._in_reasoning = False
|
||||
self._reasoning_done = True
|
||||
return output
|
||||
|
||||
def drain(self) -> str:
|
||||
"""Flush buffered literal text without synthesizing a closing tag."""
|
||||
output = self._buffer
|
||||
self._buffer = ""
|
||||
return output
|
||||
|
||||
|
||||
def normalize_reasoning_snapshots(
|
||||
stream,
|
||||
tokenizer = None,
|
||||
cancel_event = None,
|
||||
markers: Optional[tuple[str, str]] = None,
|
||||
tools = None,
|
||||
):
|
||||
"""Normalize a prefix-monotonic cumulative text stream when supported."""
|
||||
markers = markers or detect_reasoning_channel_markers(tokenizer, tools = tools)
|
||||
if markers is None:
|
||||
yield from stream
|
||||
return
|
||||
|
||||
normalizer = ReasoningChannelNormalizer(*markers)
|
||||
raw_output = ""
|
||||
normalized_output = ""
|
||||
for snapshot in stream:
|
||||
if not snapshot.startswith(raw_output):
|
||||
raise RuntimeError("Reasoning normalization requires cumulative text snapshots")
|
||||
delta = normalizer.feed(snapshot[len(raw_output) :])
|
||||
raw_output = snapshot
|
||||
if delta:
|
||||
normalized_output += delta
|
||||
yield normalized_output
|
||||
|
||||
cancelled = cancel_event is not None and cancel_event.is_set()
|
||||
tail = normalizer.drain() if cancelled else normalizer.finish()
|
||||
if tail:
|
||||
normalized_output += tail
|
||||
yield normalized_output
|
||||
|
||||
|
||||
def detect_think_prefill(prompt: Optional[str], special_tokens = None) -> str:
|
||||
|
|
@ -166,7 +398,8 @@ def render_native_template(
|
|||
preserve_thinking: Optional[bool] = None,
|
||||
apply_fn = None,
|
||||
hf_token: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
return_metadata: bool = False,
|
||||
):
|
||||
"""Render ``messages`` + ``tools`` with the model's NATIVE chat template.
|
||||
|
||||
Some Unsloth override templates (e.g. ``mistral``, ``gemma-4``) do not emit
|
||||
|
|
@ -175,7 +408,9 @@ def render_native_template(
|
|||
tool-calling syntax. It is loaded straight from the repo (bypassing any
|
||||
override on the live tokenizer) and cached on ``model_info``. Returns the
|
||||
rendered prompt only if the native template actually emits the tools (render
|
||||
differs with vs without tools); otherwise ``None``.
|
||||
differs with vs without tools); otherwise ``None``. With ``return_metadata``,
|
||||
returns ``ChatTemplateRenderResult`` so callers can stream with the response
|
||||
protocol selected by this request's template.
|
||||
|
||||
``hf_token`` is the token the model was loaded with -- passed to the repo load
|
||||
so a gated/private model's native template can still be fetched (otherwise the
|
||||
|
|
@ -261,7 +496,16 @@ def render_native_template(
|
|||
exc,
|
||||
)
|
||||
return None
|
||||
return with_tools if with_tools != no_tools else None
|
||||
if with_tools == no_tools:
|
||||
return None
|
||||
if return_metadata:
|
||||
return ChatTemplateRenderResult(
|
||||
with_tools,
|
||||
_detect_reasoning_channel_markers_from_templates(
|
||||
_selected_template_strings_from_value(native_tpl, tools)
|
||||
),
|
||||
)
|
||||
return with_tools
|
||||
|
||||
|
||||
def render_with_native_template_fallback(
|
||||
|
|
@ -277,7 +521,8 @@ def render_with_native_template_fallback(
|
|||
preserve_thinking: Optional[bool] = None,
|
||||
apply_fn = None,
|
||||
hf_token: Optional[str] = None,
|
||||
) -> str:
|
||||
return_metadata: bool = False,
|
||||
):
|
||||
"""Return ``formatted_prompt``, swapping in a native-template render when an
|
||||
override template dropped the ``tools`` schema.
|
||||
|
||||
|
|
@ -285,9 +530,27 @@ def render_with_native_template_fallback(
|
|||
them (detected by comparison, robust against tool names in the system prompt),
|
||||
re-render with the model's native template. Shared by the transformers and MLX
|
||||
backends so both advertise tools consistently. ``hf_token`` is forwarded so a
|
||||
gated/private model's native template can still be fetched."""
|
||||
gated/private model's native template can still be fetched. With
|
||||
``return_metadata``, returns the selected prompt plus reasoning-channel markers
|
||||
for the exact template used by this request."""
|
||||
live_markers = detect_reasoning_channel_markers(tokenizer, tools = tools)
|
||||
|
||||
def _result(prompt: str, markers = live_markers):
|
||||
if return_metadata:
|
||||
return ChatTemplateRenderResult(prompt, markers)
|
||||
return prompt
|
||||
|
||||
if not tools:
|
||||
return formatted_prompt
|
||||
# Gemma 4 can emit its native reasoning protocol even when a generation-time
|
||||
# Unsloth override rendered a marker-free prompt. Preserve the live-verified
|
||||
# no-tools thinking behavior without letting cached native metadata describe
|
||||
# unrelated tool prompts that kept the active override.
|
||||
markers = live_markers
|
||||
if markers is None:
|
||||
markers = detect_reasoning_channel_markers_from_model_info(
|
||||
tokenizer, model_info, tools = None
|
||||
)
|
||||
return _result(formatted_prompt, markers)
|
||||
if apply_fn is None:
|
||||
apply_fn = apply_chat_template_for_generation
|
||||
# Probe whether the live template dropped the schema. A tools-requiring template
|
||||
|
|
@ -307,9 +570,9 @@ def render_with_native_template_fallback(
|
|||
active_model_name,
|
||||
exc,
|
||||
)
|
||||
return formatted_prompt
|
||||
return _result(formatted_prompt)
|
||||
if formatted_prompt != probe_no_tools:
|
||||
return formatted_prompt # template already emits the tools schema
|
||||
return _result(formatted_prompt) # template already emits the tools schema
|
||||
native_prompt = render_native_template(
|
||||
model_info = model_info,
|
||||
active_model_name = active_model_name,
|
||||
|
|
@ -320,6 +583,7 @@ def render_with_native_template_fallback(
|
|||
preserve_thinking = preserve_thinking,
|
||||
apply_fn = apply_fn,
|
||||
hf_token = hf_token,
|
||||
return_metadata = return_metadata,
|
||||
)
|
||||
if native_prompt:
|
||||
logger.info(
|
||||
|
|
@ -328,4 +592,4 @@ def render_with_native_template_fallback(
|
|||
active_model_name,
|
||||
)
|
||||
return native_prompt
|
||||
return formatted_prompt
|
||||
return _result(formatted_prompt)
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@
|
|||
"""Bundled chat-template selection for GGUF inference.
|
||||
|
||||
Some shipped GGUF quants embed an older chat template. Rather than re-cutting and
|
||||
asking users to re-download every quant, Studio can override the embedded template
|
||||
asking users to re-download every quant, Unsloth can override the embedded template
|
||||
at llama-server launch time with a bundled, up-to-date Jinja template for known
|
||||
model families. The override is wired through the existing ``chat_template_override``
|
||||
-> ``--chat-template-file`` path in ``LlamaCppBackend.load_model``.
|
||||
|
||||
Currently this covers ``unsloth/gemma-4-*-GGUF``, which gains the upstream PR #118
|
||||
``preserve_thinking`` flag (defaulted OFF here) so the Studio "Preserve thinking"
|
||||
``preserve_thinking`` flag (defaulted OFF here) so the Unsloth "Preserve thinking"
|
||||
toggle appears while staying disabled by default.
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -473,7 +473,7 @@ def _apply_mistral_reasoning_controls(
|
|||
# handles every provider without storing credentials.
|
||||
def _create_shared_http_client() -> httpx.AsyncClient:
|
||||
# Unsupported env proxy schemes (socks:// etc) raise at construction and
|
||||
# would crash Studio startup (#6090); retry ignoring env proxies instead.
|
||||
# would crash Unsloth startup (#6090); retry ignoring env proxies instead.
|
||||
try:
|
||||
return httpx.AsyncClient()
|
||||
except (ImportError, ValueError) as exc:
|
||||
|
|
@ -858,7 +858,7 @@ class ExternalProviderClient:
|
|||
if not self._is_openai_compatible():
|
||||
# Gemini speaks its own native REST shape (contents/parts);
|
||||
# `_stream_gemini` translates request/response into the OpenAI
|
||||
# Chat Completions chunk format the rest of Studio expects.
|
||||
# Chat Completions chunk format the rest of Unsloth expects.
|
||||
# API ref: https://ai.google.dev/gemini-api/docs
|
||||
if self.provider_type == "gemini":
|
||||
async for line in self._stream_gemini(
|
||||
|
|
@ -1706,7 +1706,7 @@ class ExternalProviderClient:
|
|||
# Translate OpenAI multimodal parts -> Anthropic native shapes.
|
||||
# - `image_url` -> `{type:"image", source:...}`
|
||||
# - `input_document` -> `{type:"document", source:...}`
|
||||
# (Studio extension; mirrors Anthropic's document block,
|
||||
# (Unsloth extension; mirrors Anthropic's document block,
|
||||
# which supports PDFs as base64 or URL per
|
||||
# https://platform.claude.com/docs/en/build-with-claude/vision)
|
||||
anthropic_parts: list[dict[str, Any]] = []
|
||||
|
|
@ -1749,7 +1749,7 @@ class ExternalProviderClient:
|
|||
}
|
||||
)
|
||||
elif part.get("type") == "input_document":
|
||||
# Studio's normalised PDF/doc type (file_data data-URI or
|
||||
# Unsloth's normalised PDF/doc type (file_data data-URI or
|
||||
# file_url) -> Anthropic's native `document` block.
|
||||
url = part.get("file_url") or ""
|
||||
data_uri = part.get("file_data") or ""
|
||||
|
|
@ -4704,7 +4704,7 @@ class ExternalProviderClient:
|
|||
{"type": "image_generation_call", "id": call_id}
|
||||
)
|
||||
elif part_type == "input_document":
|
||||
# Map Studio's `input_document` onto Responses' `input_file`.
|
||||
# Map Unsloth's `input_document` onto Responses' `input_file`.
|
||||
# https://developers.openai.com/api/docs/guides/images-vision
|
||||
file_url = part.get("file_url")
|
||||
file_data = part.get("file_data")
|
||||
|
|
@ -6010,7 +6010,7 @@ class ExternalProviderClient:
|
|||
if not models and self.provider_type == "ollama":
|
||||
models = await self._list_ollama_native_models()
|
||||
# Gemini's native /v1beta/models uses a different shape; repackage
|
||||
# into the OpenAI-compatible one Studio expects.
|
||||
# into the OpenAI-compatible one Unsloth expects.
|
||||
if not models and self.provider_type == "gemini":
|
||||
models = self._parse_gemini_models(data)
|
||||
return models
|
||||
|
|
@ -6213,7 +6213,7 @@ def _friendly_provider_error_text(
|
|||
*,
|
||||
model: str | None = None,
|
||||
) -> str:
|
||||
"""Rewrite common provider errors into actionable Studio copy."""
|
||||
"""Rewrite common provider errors into actionable Unsloth copy."""
|
||||
if status_code == 404 and model:
|
||||
lowered = raw_message.lower()
|
||||
if "not found" in lowered or "not_found" in lowered:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
from unsloth import FastLanguageModel, FastVisionModel
|
||||
from unsloth.chat_templates import get_chat_template
|
||||
from transformers import TextStreamer
|
||||
from transformers import TextIteratorStreamer, TextStreamer
|
||||
from peft import PeftModel, PeftModelForCausalLM
|
||||
|
||||
import json
|
||||
|
|
@ -32,6 +32,11 @@ from core.inference.chat_eos import (
|
|||
chat_eos_repair,
|
||||
resolve_chat_turn_end_eos_ids_using,
|
||||
)
|
||||
from core.inference.chat_template_helpers import (
|
||||
ReasoningChannelNormalizer,
|
||||
detect_reasoning_channel_markers,
|
||||
detect_think_prefill,
|
||||
)
|
||||
from core.inference.presence_penalty import _make_presence_penalty_processor
|
||||
from io import StringIO
|
||||
import structlog
|
||||
|
|
@ -187,6 +192,53 @@ class HarmonyTextStreamer:
|
|||
self._queue.put(new_content)
|
||||
|
||||
|
||||
class ReasoningTextIteratorStreamer(TextIteratorStreamer):
|
||||
"""TextIteratorStreamer that preserves native channel tokens until parsed."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tokenizer,
|
||||
*,
|
||||
markers: tuple[str, str],
|
||||
skip_prompt: bool = True,
|
||||
timeout: float = 0.2,
|
||||
cancel_event = None,
|
||||
**decode_kwargs,
|
||||
):
|
||||
decode_kwargs["skip_special_tokens"] = False
|
||||
super().__init__(tokenizer, skip_prompt = skip_prompt, timeout = timeout, **decode_kwargs)
|
||||
self._normalizer = ReasoningChannelNormalizer(*markers)
|
||||
self._cancel_event = cancel_event
|
||||
self._aborted = False
|
||||
|
||||
def abort(self):
|
||||
"""Mark generation as failed so ``end`` drains without closing."""
|
||||
self._aborted = True
|
||||
|
||||
def on_finalized_text(
|
||||
self,
|
||||
text: str,
|
||||
stream_end: bool = False,
|
||||
):
|
||||
"""Queue canonical deltas, closing only on natural stream completion."""
|
||||
delta = self._normalizer.feed(text)
|
||||
if delta:
|
||||
self.text_queue.put(delta, timeout = self.timeout)
|
||||
|
||||
if stream_end:
|
||||
cancelled = self._aborted or (
|
||||
self._cancel_event is not None and self._cancel_event.is_set()
|
||||
)
|
||||
tail = self._normalizer.drain() if cancelled else self._normalizer.finish()
|
||||
if tail:
|
||||
self.text_queue.put(tail, timeout = self.timeout)
|
||||
self.text_queue.put(self.stop_signal, timeout = self.timeout)
|
||||
|
||||
|
||||
class _GenerationThreadError(RuntimeError):
|
||||
"""Generation worker failures that should propagate through stream routes."""
|
||||
|
||||
|
||||
class InferenceBackend:
|
||||
"""Unified inference backend supporting text, vision, and LoRA models"""
|
||||
|
||||
|
|
@ -836,6 +888,7 @@ class InferenceBackend:
|
|||
thread_id: Optional[str] = None,
|
||||
rag_scope: Optional[dict] = None,
|
||||
presence_penalty: float = 0.0,
|
||||
reasoning_prefilled: bool = False,
|
||||
):
|
||||
"""Run an agentic tool loop on top of ``generate_chat_response``.
|
||||
|
||||
|
|
@ -889,6 +942,7 @@ class InferenceBackend:
|
|||
session_id = session_id,
|
||||
thread_id = thread_id,
|
||||
rag_scope = rag_scope,
|
||||
reasoning_prefilled = reasoning_prefilled,
|
||||
)
|
||||
|
||||
def generate_chat_response(
|
||||
|
|
@ -960,8 +1014,7 @@ class InferenceBackend:
|
|||
thread can toggle adapters under the generation lock.
|
||||
"""
|
||||
if not self.active_model_name:
|
||||
yield "Error: No active model"
|
||||
return
|
||||
raise RuntimeError("No active model")
|
||||
|
||||
model_info = self.models[self.active_model_name]
|
||||
is_vision = model_info.get("is_vision", False)
|
||||
|
|
@ -1049,6 +1102,7 @@ class InferenceBackend:
|
|||
template_messages = [{"role": "system", "content": system_prompt}] + messages
|
||||
else:
|
||||
template_messages = messages
|
||||
reasoning_channel_markers_resolved = False
|
||||
try:
|
||||
if not (hasattr(tokenizer, "chat_template") and tokenizer.chat_template):
|
||||
raise ValueError(
|
||||
|
|
@ -1058,6 +1112,7 @@ class InferenceBackend:
|
|||
f"Please use a model that includes a chat template, or manually set "
|
||||
f"one via tokenizer.chat_template before inference."
|
||||
)
|
||||
reasoning_channel_markers = None
|
||||
formatted_prompt = self._apply_chat_template_for_generation(
|
||||
tokenizer,
|
||||
template_messages,
|
||||
|
|
@ -1073,7 +1128,7 @@ class InferenceBackend:
|
|||
render_with_native_template_fallback,
|
||||
)
|
||||
|
||||
formatted_prompt = render_with_native_template_fallback(
|
||||
render_result = render_with_native_template_fallback(
|
||||
formatted_prompt = formatted_prompt,
|
||||
tokenizer = tokenizer,
|
||||
model_info = model_info,
|
||||
|
|
@ -1085,13 +1140,19 @@ class InferenceBackend:
|
|||
preserve_thinking = preserve_thinking,
|
||||
apply_fn = self._apply_chat_template_for_generation,
|
||||
hf_token = model_info.get("hf_token"),
|
||||
return_metadata = True,
|
||||
)
|
||||
formatted_prompt = render_result.prompt
|
||||
reasoning_channel_markers = render_result.reasoning_channel_markers
|
||||
reasoning_channel_markers_resolved = True
|
||||
|
||||
logger.debug(f"Formatted prompt: {formatted_prompt[:200]}...")
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying chat template: {e}")
|
||||
# Fall back to manual formatting
|
||||
formatted_prompt = self.format_chat_prompt(messages, system_prompt)
|
||||
reasoning_channel_markers = None
|
||||
reasoning_channel_markers_resolved = True
|
||||
|
||||
# Step 3: generate
|
||||
yield from self.generate_stream(
|
||||
|
|
@ -1105,6 +1166,8 @@ class InferenceBackend:
|
|||
cancel_event = cancel_event,
|
||||
_adapter_state = _adapter_state,
|
||||
presence_penalty = presence_penalty,
|
||||
reasoning_channel_markers = reasoning_channel_markers,
|
||||
reasoning_channel_markers_resolved = reasoning_channel_markers_resolved,
|
||||
)
|
||||
|
||||
def _generate_vision_response(
|
||||
|
|
@ -1190,21 +1253,27 @@ class InferenceBackend:
|
|||
|
||||
# Stream with TextIteratorStreamer + background thread
|
||||
try:
|
||||
from core.inference.chat_template_helpers import detect_think_prefill
|
||||
|
||||
# Re-emit an open <think> prefill swallowed by skip_prompt (see
|
||||
# generate_stream).
|
||||
think_prefix = detect_think_prefill(
|
||||
prompt_text, getattr(raw_tokenizer, "all_special_tokens", None)
|
||||
)
|
||||
from transformers import TextIteratorStreamer
|
||||
import threading
|
||||
|
||||
streamer = TextIteratorStreamer(
|
||||
streamer = self._make_text_streamer(
|
||||
raw_tokenizer,
|
||||
protocol_source = processor,
|
||||
# The text-only VLM fallback above did not render with the
|
||||
# processor template, so its native markers do not describe
|
||||
# this request's response protocol.
|
||||
reasoning_channel_markers = detect_reasoning_channel_markers(processor)
|
||||
if image
|
||||
else None,
|
||||
reasoning_channel_markers_resolved = True,
|
||||
skip_prompt = True,
|
||||
skip_special_tokens = True,
|
||||
timeout = 0.2,
|
||||
cancel_event = cancel_event,
|
||||
use_harmony = self._is_gpt_oss_model(),
|
||||
)
|
||||
|
||||
generation_kwargs = dict(
|
||||
|
|
@ -1226,6 +1295,10 @@ class InferenceBackend:
|
|||
)
|
||||
if _pp is not None:
|
||||
generation_kwargs["logits_processor"] = _pp
|
||||
stopping_criteria = self._cancel_stopping_criteria(cancel_event)
|
||||
if stopping_criteria is not None:
|
||||
generation_kwargs["stopping_criteria"] = stopping_criteria
|
||||
active_stop_token_ids = self._generation_stop_token_ids(model, generation_kwargs)
|
||||
|
||||
err: dict[str, str] = {}
|
||||
|
||||
|
|
@ -1235,6 +1308,8 @@ class InferenceBackend:
|
|||
model.generate(**generation_kwargs)
|
||||
except Exception as e:
|
||||
err["msg"] = str(e)
|
||||
if hasattr(streamer, "abort"):
|
||||
streamer.abort()
|
||||
logger.error(f"Vision generation error in thread: {e}")
|
||||
finally:
|
||||
try:
|
||||
|
|
@ -1251,12 +1326,17 @@ class InferenceBackend:
|
|||
if think_prefix:
|
||||
yield think_prefix
|
||||
from queue import Empty
|
||||
import time
|
||||
|
||||
generation_complete = False
|
||||
cancel_deadline = None
|
||||
try:
|
||||
while True:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
break
|
||||
if cancel_deadline is None:
|
||||
cancel_deadline = time.monotonic() + 10
|
||||
elif time.monotonic() >= cancel_deadline:
|
||||
break
|
||||
try:
|
||||
new_token = next(streamer)
|
||||
except StopIteration:
|
||||
|
|
@ -1265,27 +1345,48 @@ class InferenceBackend:
|
|||
except Empty:
|
||||
if not thread.is_alive():
|
||||
generation_complete = True
|
||||
output = yield from self._drain_streamer_tail(
|
||||
streamer, output, active_stop_token_ids
|
||||
)
|
||||
break
|
||||
if cancel_deadline is not None:
|
||||
remaining = cancel_deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
thread.join(timeout = remaining)
|
||||
if thread.is_alive():
|
||||
break
|
||||
generation_complete = True
|
||||
output = yield from self._drain_streamer_tail(
|
||||
streamer, output, active_stop_token_ids
|
||||
)
|
||||
break
|
||||
continue
|
||||
if new_token:
|
||||
output += new_token
|
||||
cleaned = self._clean_generated_text(output)
|
||||
output, cleaned = self._append_stream_delta(
|
||||
output, new_token, active_stop_token_ids
|
||||
)
|
||||
yield cleaned
|
||||
finally:
|
||||
if cancel_event is not None and not generation_complete:
|
||||
cancel_event.set()
|
||||
thread.join(timeout = 10)
|
||||
join_timeout = 10
|
||||
if cancel_deadline is not None:
|
||||
join_timeout = max(0, cancel_deadline - time.monotonic())
|
||||
thread.join(timeout = join_timeout)
|
||||
if thread.is_alive():
|
||||
logger.warning(
|
||||
"Vision generation thread did not exit after cancel/join timeout"
|
||||
)
|
||||
|
||||
if err.get("msg"):
|
||||
yield f"Error: {err['msg']}"
|
||||
raise _GenerationThreadError(err["msg"])
|
||||
|
||||
except _GenerationThreadError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Vision generation error: {e}")
|
||||
yield f"Error: {str(e)}"
|
||||
raise
|
||||
|
||||
def generate_audio_input_response(
|
||||
self,
|
||||
|
|
@ -1410,11 +1511,13 @@ class InferenceBackend:
|
|||
)
|
||||
|
||||
if err.get("msg"):
|
||||
yield f"Error: {err['msg']}"
|
||||
raise _GenerationThreadError(err["msg"])
|
||||
|
||||
except _GenerationThreadError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Audio input generation error: {e}")
|
||||
yield f"Error: {str(e)}"
|
||||
raise
|
||||
|
||||
def generate_whisper_response(
|
||||
self,
|
||||
|
|
@ -1447,6 +1550,86 @@ class InferenceBackend:
|
|||
from utils.datasets import is_gpt_oss_model_name
|
||||
return is_gpt_oss_model_name(model_name or self.active_model_name or "")
|
||||
|
||||
def _make_text_streamer(
|
||||
self,
|
||||
tokenizer,
|
||||
*,
|
||||
protocol_source = None,
|
||||
reasoning_channel_markers = None,
|
||||
reasoning_channel_markers_resolved: bool = False,
|
||||
skip_prompt: bool = True,
|
||||
timeout: float = 0.2,
|
||||
cancel_event = None,
|
||||
use_harmony: bool = False,
|
||||
):
|
||||
"""Create the streamer matching this model's native response protocol."""
|
||||
if use_harmony:
|
||||
try:
|
||||
return HarmonyTextStreamer(
|
||||
tokenizer,
|
||||
skip_prompt = skip_prompt,
|
||||
timeout = timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"HarmonyTextStreamer init failed, falling back: {e}")
|
||||
return TextIteratorStreamer(
|
||||
tokenizer,
|
||||
skip_prompt = skip_prompt,
|
||||
skip_special_tokens = True,
|
||||
timeout = timeout,
|
||||
)
|
||||
|
||||
markers = (
|
||||
reasoning_channel_markers
|
||||
if reasoning_channel_markers_resolved
|
||||
else reasoning_channel_markers
|
||||
or detect_reasoning_channel_markers(protocol_source or tokenizer)
|
||||
)
|
||||
if markers is not None:
|
||||
return ReasoningTextIteratorStreamer(
|
||||
tokenizer,
|
||||
markers = markers,
|
||||
skip_prompt = skip_prompt,
|
||||
timeout = timeout,
|
||||
cancel_event = cancel_event,
|
||||
)
|
||||
return TextIteratorStreamer(
|
||||
tokenizer,
|
||||
skip_prompt = skip_prompt,
|
||||
skip_special_tokens = True,
|
||||
timeout = timeout,
|
||||
)
|
||||
|
||||
def _append_stream_delta(
|
||||
self,
|
||||
output: str,
|
||||
new_token: str,
|
||||
stop_token_ids = None,
|
||||
):
|
||||
"""Append a streamer delta and apply response-boundary cleanup."""
|
||||
output += new_token
|
||||
return output, self._clean_generated_text(output, stop_token_ids = stop_token_ids)
|
||||
|
||||
def _drain_streamer_tail(
|
||||
self,
|
||||
streamer,
|
||||
output: str,
|
||||
stop_token_ids = None,
|
||||
):
|
||||
"""Drain queued streamer text after the producer exits."""
|
||||
while True:
|
||||
try:
|
||||
new_token = next(streamer)
|
||||
except StopIteration:
|
||||
return output
|
||||
except Exception:
|
||||
return output
|
||||
if new_token:
|
||||
output, cleaned = self._append_stream_delta(
|
||||
output, new_token, stop_token_ids = stop_token_ids
|
||||
)
|
||||
yield cleaned
|
||||
|
||||
def generate_stream(
|
||||
self,
|
||||
prompt: str,
|
||||
|
|
@ -1459,6 +1642,8 @@ class InferenceBackend:
|
|||
cancel_event = None,
|
||||
_adapter_state = None,
|
||||
presence_penalty: float = 0.0,
|
||||
reasoning_channel_markers = None,
|
||||
reasoning_channel_markers_resolved: bool = False,
|
||||
) -> Generator[str, None, None]:
|
||||
"""Generate a streaming text response (text models only).
|
||||
|
||||
|
|
@ -1467,8 +1652,7 @@ class InferenceBackend:
|
|||
``presence_penalty`` matches the GGUF sampling path via a logits processor (0 disables it).
|
||||
"""
|
||||
if not self.active_model_name:
|
||||
yield "Error: No active model"
|
||||
return
|
||||
raise RuntimeError("No active model")
|
||||
|
||||
model_info = self.models[self.active_model_name]
|
||||
model = model_info["model"]
|
||||
|
|
@ -1481,9 +1665,7 @@ class InferenceBackend:
|
|||
try:
|
||||
inputs = tokenizer(prompt, return_tensors = "pt").to(model.device)
|
||||
|
||||
from transformers import TextIteratorStreamer
|
||||
import threading
|
||||
from core.inference.chat_template_helpers import detect_think_prefill
|
||||
|
||||
# skip_prompt swallows an open <think> prefilled by the template;
|
||||
# re-emit it so the frontend can render the thinking block.
|
||||
|
|
@ -1494,30 +1676,16 @@ class InferenceBackend:
|
|||
else detect_think_prefill(prompt, getattr(tokenizer, "all_special_tokens", None))
|
||||
)
|
||||
|
||||
# gpt-oss models: HarmonyTextStreamer parses the multi-channel
|
||||
# harmony protocol into <think> tags
|
||||
if self._is_gpt_oss_model():
|
||||
try:
|
||||
streamer = HarmonyTextStreamer(
|
||||
tokenizer,
|
||||
skip_prompt = True,
|
||||
timeout = 0.2,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"HarmonyTextStreamer init failed, falling back: {e}")
|
||||
streamer = TextIteratorStreamer(
|
||||
tokenizer,
|
||||
skip_prompt = True,
|
||||
skip_special_tokens = True,
|
||||
timeout = 0.2,
|
||||
)
|
||||
else:
|
||||
streamer = TextIteratorStreamer(
|
||||
tokenizer,
|
||||
skip_prompt = True,
|
||||
skip_special_tokens = True,
|
||||
timeout = 0.2,
|
||||
)
|
||||
streamer = self._make_text_streamer(
|
||||
tokenizer,
|
||||
protocol_source = model_info.get("tokenizer"),
|
||||
reasoning_channel_markers = reasoning_channel_markers,
|
||||
reasoning_channel_markers_resolved = reasoning_channel_markers_resolved,
|
||||
skip_prompt = True,
|
||||
timeout = 0.2,
|
||||
cancel_event = cancel_event,
|
||||
use_harmony = self._is_gpt_oss_model(),
|
||||
)
|
||||
|
||||
generation_kwargs = dict(
|
||||
**inputs,
|
||||
|
|
@ -1535,27 +1703,16 @@ class InferenceBackend:
|
|||
if tokenizer.pad_token_id is None
|
||||
else tokenizer.pad_token_id,
|
||||
)
|
||||
active_stop_token_ids = self._generation_stop_token_ids(model, generation_kwargs)
|
||||
# Presence penalty (GGUF parity); prompt_len excludes prompt tokens.
|
||||
_pp = _make_presence_penalty_processor(
|
||||
presence_penalty, int(inputs["input_ids"].shape[1])
|
||||
)
|
||||
if _pp is not None:
|
||||
generation_kwargs["logits_processor"] = _pp
|
||||
if cancel_event is not None:
|
||||
from transformers.generation.stopping_criteria import (
|
||||
StoppingCriteria,
|
||||
StoppingCriteriaList,
|
||||
)
|
||||
class _CancelCriteria(StoppingCriteria):
|
||||
def __init__(self, ev):
|
||||
self.ev = ev
|
||||
|
||||
def __call__(self, input_ids, scores, **kwargs):
|
||||
return self.ev.is_set()
|
||||
|
||||
generation_kwargs["stopping_criteria"] = StoppingCriteriaList(
|
||||
[_CancelCriteria(cancel_event)]
|
||||
)
|
||||
stopping_criteria = self._cancel_stopping_criteria(cancel_event)
|
||||
if stopping_criteria is not None:
|
||||
generation_kwargs["stopping_criteria"] = stopping_criteria
|
||||
|
||||
def generate_fn():
|
||||
with self._generation_lock:
|
||||
|
|
@ -1565,6 +1722,8 @@ class InferenceBackend:
|
|||
model.generate(**generation_kwargs)
|
||||
except Exception as e:
|
||||
err["msg"] = str(e)
|
||||
if hasattr(streamer, "abort"):
|
||||
streamer.abort()
|
||||
logger.error(f"Generation error: {e}")
|
||||
finally:
|
||||
try:
|
||||
|
|
@ -1582,12 +1741,17 @@ class InferenceBackend:
|
|||
if think_prefix:
|
||||
yield think_prefix
|
||||
from queue import Empty
|
||||
import time
|
||||
|
||||
generation_complete = False
|
||||
cancel_deadline = None
|
||||
try:
|
||||
while True:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
break
|
||||
if cancel_deadline is None:
|
||||
cancel_deadline = time.monotonic() + 10
|
||||
elif time.monotonic() >= cancel_deadline:
|
||||
break
|
||||
try:
|
||||
new_token = next(streamer)
|
||||
except StopIteration:
|
||||
|
|
@ -1596,11 +1760,27 @@ class InferenceBackend:
|
|||
except Empty:
|
||||
if not thread.is_alive():
|
||||
generation_complete = True
|
||||
output = yield from self._drain_streamer_tail(
|
||||
streamer, output, active_stop_token_ids
|
||||
)
|
||||
break
|
||||
if cancel_deadline is not None:
|
||||
remaining = cancel_deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
thread.join(timeout = remaining)
|
||||
if thread.is_alive():
|
||||
break
|
||||
generation_complete = True
|
||||
output = yield from self._drain_streamer_tail(
|
||||
streamer, output, active_stop_token_ids
|
||||
)
|
||||
break
|
||||
continue
|
||||
if new_token:
|
||||
output += new_token
|
||||
cleaned = self._clean_generated_text(output)
|
||||
output, cleaned = self._append_stream_delta(
|
||||
output, new_token, active_stop_token_ids
|
||||
)
|
||||
yield cleaned
|
||||
finally:
|
||||
# Set cancel_event only on early exit (user cancel), NOT on
|
||||
|
|
@ -1609,16 +1789,21 @@ class InferenceBackend:
|
|||
# disrupt the next serialized request (e.g. compare mode).
|
||||
if cancel_event is not None and not generation_complete:
|
||||
cancel_event.set()
|
||||
thread.join(timeout = 10)
|
||||
join_timeout = 10
|
||||
if cancel_deadline is not None:
|
||||
join_timeout = max(0, cancel_deadline - time.monotonic())
|
||||
thread.join(timeout = join_timeout)
|
||||
if thread.is_alive():
|
||||
logger.warning("Generation thread did not exit after cancel/join timeout")
|
||||
|
||||
if err.get("msg"):
|
||||
yield f"Error: {err['msg']}"
|
||||
raise _GenerationThreadError(err["msg"])
|
||||
|
||||
except _GenerationThreadError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error during generation: {e}")
|
||||
yield f"Error: {str(e)}"
|
||||
raise
|
||||
|
||||
# ── Audio (TTS) Generation ────────────────────────────────────
|
||||
|
||||
|
|
@ -2107,8 +2292,42 @@ class InferenceBackend:
|
|||
return img.resize(new_size, Image.Resampling.LANCZOS)
|
||||
return img
|
||||
|
||||
def _clean_generated_text(self, text: str) -> str:
|
||||
"""Strip leaked special tokens using the tokenizer's own token list."""
|
||||
def _generation_stop_token_ids(self, model, generation_kwargs: dict):
|
||||
"""Return the stop-token ids active for a ``generate`` call."""
|
||||
if "eos_token_id" in generation_kwargs:
|
||||
return generation_kwargs.get("eos_token_id")
|
||||
generation_config = getattr(model, "generation_config", None)
|
||||
eos_token_id = getattr(generation_config, "eos_token_id", None)
|
||||
if eos_token_id is not None:
|
||||
return eos_token_id
|
||||
config = getattr(model, "config", None)
|
||||
return getattr(config, "eos_token_id", None)
|
||||
|
||||
def _cancel_stopping_criteria(self, cancel_event):
|
||||
"""Build a Transformers stopping criteria list for user cancellation."""
|
||||
if cancel_event is None:
|
||||
return None
|
||||
from transformers.generation.stopping_criteria import (
|
||||
StoppingCriteria,
|
||||
StoppingCriteriaList,
|
||||
)
|
||||
|
||||
class _CancelCriteria(StoppingCriteria):
|
||||
def __init__(self, ev):
|
||||
self.ev = ev
|
||||
|
||||
def __call__(self, input_ids, scores, **kwargs):
|
||||
return self.ev.is_set()
|
||||
|
||||
return StoppingCriteriaList([_CancelCriteria(cancel_event)])
|
||||
|
||||
def _clean_generated_text(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
stop_token_ids = None,
|
||||
) -> str:
|
||||
"""Strip leaked response-boundary tokens after streaming."""
|
||||
if self._is_gpt_oss_model():
|
||||
# HarmonyTextStreamer emits clean <think>...</think>. Strip any
|
||||
# harmony protocol tokens and other gpt-oss tokens (e.g.
|
||||
|
|
@ -2118,10 +2337,28 @@ class InferenceBackend:
|
|||
return text.strip()
|
||||
|
||||
tokenizer = self.models.get(self.active_model_name, {}).get("tokenizer")
|
||||
tokenizer = getattr(tokenizer, "tokenizer", tokenizer)
|
||||
if tokenizer:
|
||||
for token in getattr(tokenizer, "all_special_tokens", []):
|
||||
if token in text:
|
||||
text = text.replace(token, "")
|
||||
if stop_token_ids is None:
|
||||
stop_token_ids = self.models.get(self.active_model_name, {}).get(
|
||||
"chat_turn_end_eos_ids"
|
||||
)
|
||||
if isinstance(stop_token_ids, int):
|
||||
stop_token_ids = (stop_token_ids,)
|
||||
for token_id in stop_token_ids or ():
|
||||
try:
|
||||
token = tokenizer.convert_ids_to_tokens(int(token_id))
|
||||
except Exception:
|
||||
token = None
|
||||
if isinstance(token, str) and token and text.endswith(token):
|
||||
text = text[: -len(token)]
|
||||
elif (
|
||||
isinstance(token, str)
|
||||
and token
|
||||
and text.endswith("</think>")
|
||||
and text[: -len("</think>")].endswith(token)
|
||||
):
|
||||
text = text[: -len("</think>") - len(token)] + "</think>"
|
||||
return text.strip()
|
||||
|
||||
def _load_chat_template_info(self, model_name: str):
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -15,6 +15,7 @@ import asyncio
|
|||
import contextlib
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from loggers import get_logger
|
||||
|
||||
|
|
@ -30,6 +31,8 @@ _last_active = time.monotonic()
|
|||
# otherwise 503 against an empty backend can reload it (set on unload, cleared on
|
||||
# reload). Storing the quant means the reload restores the exact freed variant.
|
||||
_last_unloaded_model = None
|
||||
# Slot KV manifest saved by the idle unload; whoever pops it owns deleting its files.
|
||||
_kv_resume = None
|
||||
# Guards inflight bumps against the idle-check-then-unload race, and blocks new
|
||||
# inference from starting mid-swap. Process-wide, not per-loop: the backend slot is
|
||||
# shared across every event loop in the process, so a per-loop gate would let a
|
||||
|
|
@ -59,7 +62,7 @@ _INFERENCE_SUFFIXES = (
|
|||
"/messages/count_tokens", # counts via the loaded tokenizer; protect like /messages
|
||||
"/embeddings",
|
||||
"/responses",
|
||||
"/generate/stream", # Studio's own streaming route on the same llama-server
|
||||
"/generate/stream", # Unsloth's own streaming route on the same llama-server
|
||||
"/audio/generate", # direct GGUF TTS; can outlive the idle TTL
|
||||
)
|
||||
|
||||
|
|
@ -161,11 +164,17 @@ def inference_lifecycle_gate():
|
|||
return _unload_gate()
|
||||
|
||||
|
||||
def note_model_loaded() -> None:
|
||||
"""Record a successful GGUF load: stamp activity and drop any reload stash so
|
||||
a manual load clears it synchronously, not only on the next idle poll."""
|
||||
def note_model_loaded(backend = None) -> None:
|
||||
"""Stamp activity and synchronously drop any reload stash."""
|
||||
_note_activity()
|
||||
resume = take_kv_resume()
|
||||
_set_last_unloaded(None)
|
||||
if resume is None:
|
||||
return
|
||||
if backend is not None:
|
||||
restore_kv_resume(backend, resume)
|
||||
else:
|
||||
_delete_resume_files(resume)
|
||||
|
||||
|
||||
def note_model_unloaded() -> None:
|
||||
|
|
@ -182,9 +191,81 @@ def get_last_unloaded_model():
|
|||
|
||||
|
||||
def _set_last_unloaded(value) -> None:
|
||||
global _last_unloaded_model
|
||||
global _last_unloaded_model, _kv_resume
|
||||
stale = None
|
||||
with _lock:
|
||||
_last_unloaded_model = value
|
||||
if value is None and _kv_resume is not None:
|
||||
stale, _kv_resume = _kv_resume, None
|
||||
if stale:
|
||||
_delete_resume_files(stale)
|
||||
|
||||
|
||||
def _delete_resume_files(manifest) -> None:
|
||||
try:
|
||||
base = Path(manifest.get("dir") or "")
|
||||
for entry in manifest.get("slots") or []:
|
||||
with contextlib.suppress(OSError):
|
||||
(base / str(entry.get("filename"))).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _set_kv_resume(value) -> None:
|
||||
global _kv_resume
|
||||
stale = None
|
||||
with _lock:
|
||||
if _kv_resume is not None and _kv_resume is not value:
|
||||
stale = _kv_resume
|
||||
_kv_resume = value
|
||||
if stale:
|
||||
_delete_resume_files(stale)
|
||||
|
||||
|
||||
def take_kv_resume():
|
||||
global _kv_resume
|
||||
with _lock:
|
||||
manifest, _kv_resume = _kv_resume, None
|
||||
return manifest
|
||||
|
||||
|
||||
def purge_kv_resume() -> None:
|
||||
resume = take_kv_resume()
|
||||
if resume:
|
||||
_delete_resume_files(resume)
|
||||
|
||||
|
||||
def restore_kv_resume(backend, manifest) -> None:
|
||||
try:
|
||||
gguf = manifest.get("gguf")
|
||||
binary = manifest.get("binary")
|
||||
current = getattr(backend, "_gguf_path", None)
|
||||
same_gguf = bool(gguf and current) and Path(current).resolve() == Path(gguf).resolve()
|
||||
if same_gguf:
|
||||
# Same path is not enough: shards may have been rewritten meanwhile.
|
||||
identity = getattr(backend, "_gguf_file_identity", None)
|
||||
same_gguf = callable(identity) and identity(current) == manifest.get("gguf_stat")
|
||||
if same_gguf:
|
||||
# Nor the same file: launch overrides can invalidate KV numerics.
|
||||
fingerprint = getattr(backend, "_slot_launch_fingerprint", None)
|
||||
same_gguf = callable(fingerprint) and manifest.get("launch") == fingerprint()
|
||||
if same_gguf and binary and binary == getattr(backend, "_slot_save_binary", None):
|
||||
logger.info("Restoring saved slot KV onto the reloaded model")
|
||||
backend.restore_slots_for_resume(manifest)
|
||||
except Exception as exc:
|
||||
logger.debug("slot restore after reload failed: %s", exc)
|
||||
finally:
|
||||
_delete_resume_files(manifest)
|
||||
|
||||
|
||||
def sweep_slot_save_dir() -> None:
|
||||
try:
|
||||
from utils.paths.storage_roots import llama_slot_cache_root
|
||||
for path in llama_slot_cache_root().glob("resume-*.bin"):
|
||||
with contextlib.suppress(OSError):
|
||||
path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class LlamaKeepWarmMiddleware:
|
||||
|
|
@ -266,7 +347,10 @@ def _loaded_identity(backend):
|
|||
|
||||
async def idle_unload_loop(poll_seconds: float = 15.0) -> None:
|
||||
"""Unload the loaded GGUF once idle past the configured TTL. Inert when off."""
|
||||
from utils.openai_auto_switch_settings import get_auto_unload_idle_seconds
|
||||
from utils.openai_auto_switch_settings import (
|
||||
get_auto_unload_idle_seconds,
|
||||
get_auto_unload_keep_kv,
|
||||
)
|
||||
|
||||
seen_model = None
|
||||
while True:
|
||||
|
|
@ -281,17 +365,47 @@ async def idle_unload_loop(poll_seconds: float = 15.0) -> None:
|
|||
# Track by (id, variant): a (re)loaded model -- including the same repo
|
||||
# at a different quant -- counts as activity so it survives one TTL
|
||||
# before its first request (loads bypass the activity middleware).
|
||||
current = _loaded_identity(backend)
|
||||
if current != seen_model:
|
||||
seen_model = current
|
||||
if current is not None:
|
||||
_note_activity()
|
||||
_set_last_unloaded(None) # a model is loaded; drop stale stash
|
||||
async with _unload_gate():
|
||||
# Purging the stash mid-reload would race the restore.
|
||||
current = _loaded_identity(backend)
|
||||
if current != seen_model:
|
||||
seen_model = current
|
||||
if current is not None:
|
||||
_note_activity()
|
||||
_set_last_unloaded(None) # a model is loaded; drop stale stash
|
||||
if backend.is_loaded and _is_idle(ttl):
|
||||
freed = _loaded_identity(backend)
|
||||
await asyncio.to_thread(backend.unload_model)
|
||||
manifest = None
|
||||
if get_auto_unload_keep_kv():
|
||||
try:
|
||||
manifest = await asyncio.to_thread(
|
||||
backend.save_slots_for_resume,
|
||||
lambda: not _is_idle(ttl),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("slot save before idle unload failed: %s", exc)
|
||||
# Re-read settings: the save can outlive a settings change.
|
||||
ttl = get_auto_unload_idle_seconds()
|
||||
if ttl <= 0 or not _is_idle(ttl):
|
||||
if manifest:
|
||||
_delete_resume_files(manifest)
|
||||
continue
|
||||
if manifest and not get_auto_unload_keep_kv():
|
||||
_delete_resume_files(manifest)
|
||||
manifest = None
|
||||
try:
|
||||
await asyncio.to_thread(backend.unload_model)
|
||||
except Exception:
|
||||
# Failed unload means nothing will stash the manifest.
|
||||
if manifest:
|
||||
_delete_resume_files(manifest)
|
||||
raise
|
||||
_set_last_unloaded(freed) # let an alias request reload it
|
||||
if manifest and freed:
|
||||
_set_kv_resume({"identity": freed, **manifest})
|
||||
logger.info("Idle auto-unload: saved slot KV for restore on reload")
|
||||
elif manifest:
|
||||
_delete_resume_files(manifest)
|
||||
logger.info("Idle auto-unload: freed GGUF after %ss idle", ttl)
|
||||
seen_model = None
|
||||
except Exception as exc:
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
|
||||
"""Boundary validator for user-supplied llama-server pass-through args.
|
||||
|
||||
Reject only flags Studio manages (model identity, auth, network, parallel
|
||||
Reject only flags Unsloth manages (model identity, auth, network, parallel
|
||||
slots). Everything else (sampling, ``-c``, ``-ngl``, ``--flash-attn``,
|
||||
``--cache-type-*``, ``--spec-*``, ``--jinja``, ...) is appended after
|
||||
Studio's auto-set flags so llama.cpp's last-wins parser lets the user override.
|
||||
Unsloth's auto-set flags so llama.cpp's last-wins parser lets the user override.
|
||||
|
||||
Ref: https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md
|
||||
"""
|
||||
|
|
@ -22,12 +22,12 @@ _DENYLIST_GROUPS: tuple[frozenset[str], ...] = (
|
|||
# Parallel slots: owned by typer --parallel; a pass-through would desync
|
||||
# app.state.llama_parallel_slots from llama-server.
|
||||
frozenset({"-np", "--parallel", "--n-parallel"}),
|
||||
# Model identity: Studio resolves it from LoadRequest; a second -m would
|
||||
# load a different model than Studio thinks it loaded.
|
||||
# Model identity: Unsloth resolves it from LoadRequest; a second -m would
|
||||
# load a different model than Unsloth thinks it loaded.
|
||||
frozenset({"-m", "--model"}),
|
||||
# Public model id: Studio sets a sanitized --alias so the OpenAI API never
|
||||
# Public model id: Unsloth sets a sanitized --alias so the OpenAI API never
|
||||
# exposes the local .gguf path. A user-supplied alias is appended after
|
||||
# Studio's and, with llama.cpp's last-wins parsing, would reintroduce the
|
||||
# Unsloth's and, with llama.cpp's last-wins parsing, would reintroduce the
|
||||
# path leak this is meant to prevent.
|
||||
frozenset({"-a", "--alias"}),
|
||||
frozenset({"-mu", "--model-url"}),
|
||||
|
|
@ -39,14 +39,14 @@ _DENYLIST_GROUPS: tuple[frozenset[str], ...] = (
|
|||
frozenset({"-hft", "--hf-token"}),
|
||||
frozenset({"-mm", "--mmproj"}),
|
||||
frozenset({"-mmu", "--mmproj-url"}),
|
||||
# Networking: Studio binds + proxies; retargeting orphans the proxy.
|
||||
# Networking: Unsloth binds + proxies; retargeting orphans the proxy.
|
||||
frozenset({"--host"}),
|
||||
frozenset({"--port"}),
|
||||
frozenset({"--path"}),
|
||||
frozenset({"--api-prefix"}),
|
||||
frozenset({"--reuse-port"}),
|
||||
# Auth / TLS: Studio terminates auth; upstream --api-key / TLS shadows
|
||||
# Studio's key and breaks the proxy hop.
|
||||
# Auth / TLS: Unsloth terminates auth; upstream --api-key / TLS shadows
|
||||
# Unsloth's key and breaks the proxy hop.
|
||||
frozenset({"--api-key"}),
|
||||
frozenset({"--api-key-file"}),
|
||||
frozenset({"--ssl-key-file"}),
|
||||
|
|
@ -64,12 +64,14 @@ _DENYLIST_GROUPS: tuple[frozenset[str], ...] = (
|
|||
frozenset({"--models-max"}),
|
||||
frozenset({"--models-autoload", "--no-models-autoload"}),
|
||||
# Server-mode flips: --embedding / --rerank restrict llama-server to
|
||||
# those endpoints, breaking Studio's /v1/chat/completions hop.
|
||||
# those endpoints, breaking Unsloth's /v1/chat/completions hop.
|
||||
frozenset({"--embedding", "--embeddings"}),
|
||||
frozenset({"--rerank", "--reranking"}),
|
||||
# llama-server's own built-in tools flag would silently stack on top of
|
||||
# Studio's --enable-tools / --disable-tools policy resolver.
|
||||
# Unsloth's --enable-tools / --disable-tools policy resolver.
|
||||
frozenset({"--tools"}),
|
||||
# Slot-state dir: Studio owns it for KV persistence across idle unload.
|
||||
frozenset({"--slot-save-path"}),
|
||||
)
|
||||
|
||||
_DENYLIST: frozenset[str] = frozenset().union(*_DENYLIST_GROUPS)
|
||||
|
|
@ -120,7 +122,7 @@ def validate_extra_args(args: Optional[Iterable[str]]) -> list[str]:
|
|||
|
||||
|
||||
def is_managed_flag(flag: str) -> bool:
|
||||
"""True if ``flag`` is Studio-managed. Normalises via ``_flag_name`` so
|
||||
"""True if ``flag`` is Unsloth-managed. Normalises via ``_flag_name`` so
|
||||
`-np8` / `--parallel=8` classify like the canonical tokens."""
|
||||
normalised = _flag_name(flag)
|
||||
return normalised is not None and normalised in _DENYLIST
|
||||
|
|
@ -142,7 +144,7 @@ _SPEC_FLAGS: frozenset[str] = frozenset(
|
|||
"--draft-min",
|
||||
"--draft-max",
|
||||
# MTP path (llama.cpp #22673). The drafter selectors (local --model-draft
|
||||
# and HF --spec-draft-hf aliases) are Studio-managed since the separate-
|
||||
# and HF --spec-draft-hf aliases) are Unsloth-managed since the separate-
|
||||
# drafter support (Gemma 4): an inherited copy must not last-wins-override
|
||||
# the auto-detected drafter. Explicit extras for the current load are never
|
||||
# stripped. The per-drafter tuning knobs (--spec-draft-type-*, -ngld,
|
||||
|
|
@ -179,25 +181,38 @@ _TEMPLATE_FLAGS: frozenset[str] = frozenset(
|
|||
# (--split-mode tensor). Pass-through stays allowed so users keep the
|
||||
# row/none/layer modes the toggle doesn't expose, but it's stripped on
|
||||
# inherit and reconciled into the round-tripped tensor_parallel state.
|
||||
# --tensor-split is coupled to the split mode and is stripped with it: Studio
|
||||
# --tensor-split is coupled to the split mode and is stripped with it: Unsloth
|
||||
# owns the tensor-mode split ratios, so an inherited/stale --tensor-split must
|
||||
# not last-wins-override Studio's computed asymmetric split.
|
||||
# not last-wins-override Unsloth's computed asymmetric split.
|
||||
_SPLIT_MODE_FLAGS: frozenset[str] = frozenset({"-sm", "--split-mode"})
|
||||
_TENSOR_SPLIT_FLAGS: frozenset[str] = frozenset({"-ts", "--tensor-split"})
|
||||
_SPLIT_SHADOWING_FLAGS: frozenset[str] = _SPLIT_MODE_FLAGS | _TENSOR_SPLIT_FLAGS
|
||||
|
||||
# GPU-offload flags. Stripped only when the GPU Memory mode owns offload
|
||||
# (manual emits --fit / --gpu-layers / --n-cpu-moe); in auto, a user's
|
||||
# inherited -ngl is respected (the offload_overridden path), so this group is
|
||||
# opt-in, not default. Layer flags are shared with llama_cpp's override
|
||||
# detection; the MoE flags are strip-only (manual's --n-cpu-moe slider owns them).
|
||||
_LAYER_OFFLOAD_FLAGS: frozenset[str] = frozenset(
|
||||
{"-ngl", "--gpu-layers", "--n-gpu-layers", "-fit", "--fit"}
|
||||
)
|
||||
_MOE_OFFLOAD_FLAGS: frozenset[str] = frozenset({"-ncmoe", "--n-cpu-moe", "-cmoe", "--cpu-moe"})
|
||||
_OFFLOAD_SHADOWING_FLAGS: frozenset[str] = _LAYER_OFFLOAD_FLAGS | _MOE_OFFLOAD_FLAGS
|
||||
|
||||
_SHADOWING_FLAGS: frozenset[str] = (
|
||||
_CONTEXT_FLAGS | _CACHE_FLAGS | _SPEC_FLAGS | _TEMPLATE_FLAGS | _SPLIT_SHADOWING_FLAGS
|
||||
)
|
||||
|
||||
# Shadowing flags that take no value -- strip the flag only, not the next token.
|
||||
_BOOLEAN_SHADOWING_FLAGS: frozenset[str] = frozenset({"--spec-default", "--jinja", "--no-jinja"})
|
||||
_BOOLEAN_SHADOWING_FLAGS: frozenset[str] = frozenset(
|
||||
{"--spec-default", "--jinja", "--no-jinja", "-cmoe", "--cpu-moe"}
|
||||
)
|
||||
|
||||
|
||||
def parse_ctx_override(args: Optional[Iterable[str]]) -> Optional[int]:
|
||||
"""Return the last user-supplied ``-c`` / ``--ctx-size`` value.
|
||||
|
||||
Mirrors llama.cpp's last-wins parsing for the one numeric knob Studio's
|
||||
Mirrors llama.cpp's last-wins parsing for the one numeric knob Unsloth's
|
||||
load-time fit logic needs.
|
||||
"""
|
||||
if not args:
|
||||
|
|
@ -286,7 +301,7 @@ def parse_cache_override(args: Optional[Iterable[str]]) -> Optional[str]:
|
|||
Mirrors parse_ctx_override but for cache type. Recognises both -ctk
|
||||
(key) and -ctv (value). When both flags appear, returns the last-wins
|
||||
value, treating key and value cache flags as the same setting because
|
||||
Studio's KV estimate has a single cache_type_kv knob.
|
||||
Unsloth's KV estimate has a single cache_type_kv knob.
|
||||
"""
|
||||
return _last_flag_value(args, _CACHE_FLAGS)
|
||||
|
||||
|
|
@ -341,7 +356,7 @@ def resolve_tensor_parallel(args: Optional[Iterable[str]], fallback_tensor_paral
|
|||
|
||||
|
||||
def _env_split_mode_is_tensor(env: Optional[Mapping[str, str]] = None) -> bool:
|
||||
"""True when the inherited LLAMA_ARG_SPLIT_MODE env selects tensor. Studio
|
||||
"""True when the inherited LLAMA_ARG_SPLIT_MODE env selects tensor. Unsloth
|
||||
emits --split-mode only on its tensor branch, so a tensor env on the layer
|
||||
path would run the child tensor-parallel unbudgeted; this flips the budget
|
||||
to tensor. Only tensor is heavier, so other modes are ignored."""
|
||||
|
|
@ -424,14 +439,22 @@ def strip_shadowing_flags(
|
|||
strip_spec: bool = True,
|
||||
strip_template: bool = True,
|
||||
strip_split_mode: bool = True,
|
||||
strip_tensor_split: bool = False,
|
||||
strip_offload: bool = False,
|
||||
) -> list[str]:
|
||||
"""Strip flags that shadow first-class Studio settings.
|
||||
"""Strip flags that shadow first-class Unsloth settings.
|
||||
|
||||
Used when inheriting a previous load's ``llama_extra_args`` so an
|
||||
inherited `-c 4096` can't override the current `max_seq_length`
|
||||
(same for cache / spec / template / split-mode). Each ``strip_*``
|
||||
toggle controls one group; the route only strips groups whose
|
||||
first-class field the caller actually supplied.
|
||||
|
||||
``strip_split_mode`` removes both ``--split-mode`` and the coupled
|
||||
``--tensor-split`` (the Tensor Parallelism toggle owns the whole split).
|
||||
``strip_tensor_split`` removes ``--tensor-split`` *alone*, so manual mode can
|
||||
replace an inherited per-GPU ratio while leaving the user's ``--split-mode``
|
||||
row/none/layer choice intact.
|
||||
"""
|
||||
shadowing: set[str] = set()
|
||||
if strip_context:
|
||||
|
|
@ -444,6 +467,10 @@ def strip_shadowing_flags(
|
|||
shadowing |= _TEMPLATE_FLAGS
|
||||
if strip_split_mode:
|
||||
shadowing |= _SPLIT_SHADOWING_FLAGS
|
||||
if strip_tensor_split:
|
||||
shadowing |= _TENSOR_SPLIT_FLAGS
|
||||
if strip_offload:
|
||||
shadowing |= _OFFLOAD_SHADOWING_FLAGS
|
||||
|
||||
tokens = [str(a) for a in (args or [])]
|
||||
out: list[str] = []
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
engine-stats log line (generation/prompt throughput, requests in flight).
|
||||
|
||||
llama-server already computes these (it needs `--metrics`); this lifts them
|
||||
into Studio's structured log so the terminal shows serving health, not just
|
||||
into Unsloth's structured log so the terminal shows serving health, not just
|
||||
per-request access lines. Emitted only while there is activity.
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ def info_has_local_gguf(info) -> bool:
|
|||
def _build_index() -> dict[str, _LocalGgufEntry]:
|
||||
"""Map normalized id/model_id/display_name -> local GGUF entry.
|
||||
|
||||
Scans the same roots Studio's model picker lists (./models, the active plus
|
||||
Scans the same roots Unsloth's model picker lists (./models, the active plus
|
||||
legacy/default HF caches, LM Studio dirs, and user scan folders) so a named
|
||||
local model is never missed and silently served as the loaded one. Ollama's
|
||||
scanner is skipped: it creates symlinks as a side effect and this runs on the
|
||||
|
|
@ -199,9 +199,13 @@ def _build_index() -> dict[str, _LocalGgufEntry]:
|
|||
raw_id = getattr(info, "id", None)
|
||||
if not raw_id:
|
||||
continue
|
||||
# Skip what Studio hides from its pickers (validation probe, RAG embed
|
||||
# Skip what Unsloth hides from its pickers (validation probe, RAG embed
|
||||
# weights): not chat models, so never an auto-switch target.
|
||||
if _is_hidden_model(raw_id, getattr(info, "path", None)):
|
||||
if _is_hidden_model(
|
||||
raw_id,
|
||||
getattr(info, "model_id", None),
|
||||
getattr(info, "path", None),
|
||||
):
|
||||
continue
|
||||
# Advertise a client-facing alias, not an absolute filesystem path.
|
||||
loader_id = _advertised_loader_id(info)
|
||||
|
|
|
|||
|
|
@ -906,7 +906,7 @@ def _call_stdio_tool(
|
|||
def _remaining() -> Optional[float]:
|
||||
return None if deadline is None else max(0.0, deadline - time.monotonic())
|
||||
|
||||
# Callers without a Studio session id must retain the former one-shot
|
||||
# Callers without an Unsloth session id must retain the former one-shot
|
||||
# behavior: no browser/cookie/tool state can leak into another request.
|
||||
# Use an ephemeral key (and close it below) rather than the shared empty
|
||||
# scope that the persistent-session cache used previously.
|
||||
|
|
|
|||
|
|
@ -8,14 +8,76 @@ instead of torch/transformers for model loading and generation.
|
|||
import json
|
||||
import os
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional, Generator
|
||||
from core.inference.message_content import content_to_text
|
||||
from core.inference.runtime_context import runtime_context_length
|
||||
from core.inference.chat_template_helpers import (
|
||||
ReasoningChannelNormalizer,
|
||||
normalize_reasoning_snapshots,
|
||||
)
|
||||
from loggers import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _mlx_adapter_modules(model):
|
||||
"""Return bypassable adapter entries and unsupported wrapper paths."""
|
||||
adapters = []
|
||||
unsupported = []
|
||||
for path, module in model.named_modules():
|
||||
if not path or not (hasattr(module, "lora_a") and hasattr(module, "lora_b")):
|
||||
continue
|
||||
base = getattr(module, "linear", None)
|
||||
if base is None:
|
||||
base = getattr(module, "embedding", None)
|
||||
if base is None:
|
||||
unsupported.append(path)
|
||||
else:
|
||||
adapters.append((path, module, base))
|
||||
return adapters, unsupported
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _temporary_mlx_adapter_state(model, use_adapter):
|
||||
"""Select base or adapter modules for one request, then restore the tree."""
|
||||
if use_adapter is None:
|
||||
yield
|
||||
return
|
||||
if isinstance(use_adapter, str):
|
||||
raise NotImplementedError(
|
||||
"Unsloth MLX: named adapter selection is not supported; use True for "
|
||||
"the loaded adapter or False for the base model."
|
||||
)
|
||||
if use_adapter is not True and use_adapter is not False:
|
||||
raise TypeError("Unsloth MLX: use_adapter must be None, True, False, or a string.")
|
||||
|
||||
adapters, unsupported = _mlx_adapter_modules(model)
|
||||
if use_adapter is True:
|
||||
if not adapters and not unsupported:
|
||||
logger.warning("MLX adapter requested, but the active model has no adapter layers")
|
||||
yield
|
||||
return
|
||||
if unsupported:
|
||||
raise RuntimeError(
|
||||
"Unsloth MLX: cannot disable adapter layers without their base modules: "
|
||||
+ ", ".join(unsupported[:5])
|
||||
)
|
||||
if not adapters:
|
||||
yield
|
||||
return
|
||||
|
||||
from mlx.utils import tree_unflatten
|
||||
|
||||
base_modules = tree_unflatten([(path, base) for path, _, base in adapters])
|
||||
adapter_modules = tree_unflatten([(path, wrapper) for path, wrapper, _ in adapters])
|
||||
try:
|
||||
model.update_modules(base_modules)
|
||||
yield
|
||||
finally:
|
||||
model.update_modules(adapter_modules)
|
||||
|
||||
|
||||
def _mlx_vlm_model_config(model):
|
||||
"""Return the loaded MLX model config and its type, preferring whichever of
|
||||
config / _config actually carries a model_type."""
|
||||
|
|
@ -504,6 +566,7 @@ class MLXInferenceBackend:
|
|||
reasoning_effort = None,
|
||||
preserve_thinking = None,
|
||||
presence_penalty = 0.0,
|
||||
_adapter_state = None,
|
||||
) -> Generator[str, None, None]:
|
||||
if self._model is None:
|
||||
raise RuntimeError("No model loaded")
|
||||
|
|
@ -533,7 +596,7 @@ class MLXInferenceBackend:
|
|||
break
|
||||
|
||||
if self._is_vlm:
|
||||
yield from self._generate_vlm(
|
||||
stream = self._generate_vlm(
|
||||
full_messages,
|
||||
image,
|
||||
temperature,
|
||||
|
|
@ -548,9 +611,10 @@ class MLXInferenceBackend:
|
|||
reasoning_effort = reasoning_effort,
|
||||
preserve_thinking = preserve_thinking,
|
||||
presence_penalty = presence_penalty,
|
||||
_adapter_state = _adapter_state,
|
||||
)
|
||||
else:
|
||||
yield from self._generate_text(
|
||||
stream = self._generate_text(
|
||||
full_messages,
|
||||
temperature,
|
||||
top_p,
|
||||
|
|
@ -564,7 +628,9 @@ class MLXInferenceBackend:
|
|||
reasoning_effort = reasoning_effort,
|
||||
preserve_thinking = preserve_thinking,
|
||||
presence_penalty = presence_penalty,
|
||||
_adapter_state = _adapter_state,
|
||||
)
|
||||
yield from stream
|
||||
|
||||
def _generate_text(
|
||||
self,
|
||||
|
|
@ -582,6 +648,7 @@ class MLXInferenceBackend:
|
|||
reasoning_effort = None,
|
||||
preserve_thinking = None,
|
||||
presence_penalty = 0.0,
|
||||
_adapter_state = None,
|
||||
):
|
||||
from mlx_lm import stream_generate
|
||||
from mlx_lm.sample_utils import make_sampler, make_logits_processors
|
||||
|
|
@ -609,7 +676,7 @@ class MLXInferenceBackend:
|
|||
# probe and native render share a renderer. (VLM renders via the
|
||||
# processor for image tokens and is not wired here.)
|
||||
model_info = self.models.get(self.active_model_name, {})
|
||||
prompt = render_with_native_template_fallback(
|
||||
render_result = render_with_native_template_fallback(
|
||||
formatted_prompt = prompt,
|
||||
tokenizer = self._tokenizer,
|
||||
model_info = model_info,
|
||||
|
|
@ -620,17 +687,16 @@ class MLXInferenceBackend:
|
|||
reasoning_effort = reasoning_effort,
|
||||
preserve_thinking = preserve_thinking,
|
||||
hf_token = model_info.get("hf_token"),
|
||||
return_metadata = True,
|
||||
)
|
||||
prompt = render_result.prompt
|
||||
reasoning_channel_markers = render_result.reasoning_channel_markers
|
||||
|
||||
# An open <think> prefilled by the template lives in the prompt, not
|
||||
# the generated tokens; re-emit it so the frontend renders the block.
|
||||
think_prefix = detect_think_prefill(
|
||||
prompt, getattr(self._tokenizer, "all_special_tokens", None)
|
||||
)
|
||||
# Emit it before the first token so the block renders during prefill.
|
||||
if think_prefix:
|
||||
yield think_prefix
|
||||
|
||||
sampler = make_sampler(
|
||||
temp = temperature,
|
||||
top_p = top_p,
|
||||
|
|
@ -654,7 +720,17 @@ class MLXInferenceBackend:
|
|||
if not logits_processors:
|
||||
logits_processors = None
|
||||
|
||||
preserve_native_channels = reasoning_channel_markers is not None
|
||||
token_ids = []
|
||||
normalizer = (
|
||||
ReasoningChannelNormalizer(*reasoning_channel_markers)
|
||||
if reasoning_channel_markers is not None
|
||||
else None
|
||||
)
|
||||
# MLX consumers diff cumulative snapshots. Keep a prompt-prefilled
|
||||
# <think> prefix on every native-protocol snapshot just as the normal
|
||||
# decoding path does below.
|
||||
normalized_output = think_prefix
|
||||
logger.info(
|
||||
"Generating: prompt_len=%d, max_tokens=%d, model=%s, tokenizer=%s",
|
||||
len(prompt),
|
||||
|
|
@ -662,9 +738,12 @@ class MLXInferenceBackend:
|
|||
type(self._model).__name__,
|
||||
type(self._tokenizer).__name__,
|
||||
)
|
||||
with self._generation_lock:
|
||||
with self._generation_lock, _temporary_mlx_adapter_state(self._model, _adapter_state):
|
||||
final_response = None
|
||||
try:
|
||||
# Enter request-scoped model state before yielding any response.
|
||||
if think_prefix:
|
||||
yield think_prefix
|
||||
gen_kwargs = dict(
|
||||
prompt = prompt,
|
||||
max_tokens = max_new_tokens,
|
||||
|
|
@ -678,12 +757,19 @@ class MLXInferenceBackend:
|
|||
**gen_kwargs,
|
||||
):
|
||||
final_response = response
|
||||
token_ids.append(response.token)
|
||||
cumulative = self._tokenizer.decode(
|
||||
token_ids,
|
||||
skip_special_tokens = True,
|
||||
)
|
||||
yield think_prefix + cumulative
|
||||
if preserve_native_channels:
|
||||
piece = getattr(response, "text", None) or ""
|
||||
delta = normalizer.feed(piece)
|
||||
if delta:
|
||||
normalized_output += delta
|
||||
yield normalized_output
|
||||
else:
|
||||
token_ids.append(response.token)
|
||||
cumulative = self._tokenizer.decode(
|
||||
token_ids,
|
||||
skip_special_tokens = True,
|
||||
)
|
||||
yield think_prefix + cumulative
|
||||
|
||||
if cancel_event and cancel_event.is_set():
|
||||
break
|
||||
|
|
@ -700,6 +786,12 @@ class MLXInferenceBackend:
|
|||
getattr(final_response, "generation_tokens", 0),
|
||||
getattr(final_response, "generation_tps", 0.0),
|
||||
)
|
||||
if normalizer is not None:
|
||||
cancelled = cancel_event is not None and cancel_event.is_set()
|
||||
tail = normalizer.drain() if cancelled else normalizer.finish()
|
||||
if tail:
|
||||
normalized_output += tail
|
||||
yield normalized_output
|
||||
|
||||
def _generate_vlm(
|
||||
self,
|
||||
|
|
@ -718,6 +810,7 @@ class MLXInferenceBackend:
|
|||
reasoning_effort = None,
|
||||
preserve_thinking = None,
|
||||
presence_penalty = 0.0,
|
||||
_adapter_state = None,
|
||||
):
|
||||
from mlx_vlm import stream_generate as vlm_stream
|
||||
|
||||
|
|
@ -821,9 +914,6 @@ class MLXInferenceBackend:
|
|||
|
||||
# Re-emit an open <think> prefill from the prompt (see _generate_text).
|
||||
cumulative = detect_think_prefill(prompt, getattr(chat_target, "all_special_tokens", None))
|
||||
# Emit it before the first token so the block renders during prefill.
|
||||
if cumulative:
|
||||
yield cumulative
|
||||
logger.info(
|
||||
"VLM generating: prompt_len=%d, has_image=%s",
|
||||
len(prompt),
|
||||
|
|
@ -858,31 +948,46 @@ class MLXInferenceBackend:
|
|||
elif _rep_active:
|
||||
vlm_kwargs["repetition_penalty"] = float(repetition_penalty)
|
||||
|
||||
with self._generation_lock:
|
||||
final_response = None
|
||||
try:
|
||||
for response in vlm_stream(
|
||||
self._model,
|
||||
self._processor,
|
||||
prompt,
|
||||
images,
|
||||
**vlm_kwargs,
|
||||
):
|
||||
final_response = response
|
||||
token_text = response.text if hasattr(response, "text") else str(response)
|
||||
cumulative += token_text
|
||||
yield cumulative
|
||||
if cancel_event and cancel_event.is_set():
|
||||
break
|
||||
finally:
|
||||
# mlx_vlm exposes the same stats fields as mlx_lm.
|
||||
if final_response is not None:
|
||||
self.last_generation_stats = _build_generation_stats(
|
||||
getattr(final_response, "prompt_tokens", 0),
|
||||
getattr(final_response, "prompt_tps", 0.0),
|
||||
getattr(final_response, "generation_tokens", 0),
|
||||
getattr(final_response, "generation_tps", 0.0),
|
||||
)
|
||||
def _stream_vlm_snapshots():
|
||||
nonlocal cumulative
|
||||
# Hold the generation lock AND the request-scoped adapter state for the
|
||||
# whole stream so Base-vs-LoRA compare mode honors use_adapter and the
|
||||
# wrapper tree is restored on completion, cancellation, or close.
|
||||
with self._generation_lock, _temporary_mlx_adapter_state(self._model, _adapter_state):
|
||||
final_response = None
|
||||
try:
|
||||
# Emit any prefilled <think> block before the first token so the
|
||||
# UI renders it during prefill, matching _generate_text. Done
|
||||
# inside the adapter context so an unsupported request raises
|
||||
# before any output escapes.
|
||||
if cumulative:
|
||||
yield cumulative
|
||||
for response in vlm_stream(
|
||||
self._model,
|
||||
self._processor,
|
||||
prompt,
|
||||
images,
|
||||
**vlm_kwargs,
|
||||
):
|
||||
final_response = response
|
||||
token_text = response.text if hasattr(response, "text") else str(response)
|
||||
cumulative += token_text
|
||||
yield cumulative
|
||||
if cancel_event and cancel_event.is_set():
|
||||
break
|
||||
finally:
|
||||
# mlx_vlm exposes the same stats fields as mlx_lm.
|
||||
if final_response is not None:
|
||||
self.last_generation_stats = _build_generation_stats(
|
||||
getattr(final_response, "prompt_tokens", 0),
|
||||
getattr(final_response, "prompt_tps", 0.0),
|
||||
getattr(final_response, "generation_tokens", 0),
|
||||
getattr(final_response, "generation_tps", 0.0),
|
||||
)
|
||||
|
||||
yield from normalize_reasoning_snapshots(
|
||||
_stream_vlm_snapshots(), chat_target, cancel_event, tools = tools
|
||||
)
|
||||
|
||||
def generate_with_adapter_control(
|
||||
self,
|
||||
|
|
@ -890,8 +995,11 @@ class MLXInferenceBackend:
|
|||
cancel_event = None,
|
||||
**gen_kwargs,
|
||||
) -> Generator[str, None, None]:
|
||||
# MLX LoRA adapter toggling not yet supported; generate normally
|
||||
yield from self.generate_chat_response(cancel_event = cancel_event, **gen_kwargs)
|
||||
yield from self.generate_chat_response(
|
||||
cancel_event = cancel_event,
|
||||
_adapter_state = use_adapter,
|
||||
**gen_kwargs,
|
||||
)
|
||||
|
||||
def reset_generation_state(self):
|
||||
import mlx.core as mx
|
||||
|
|
|
|||
|
|
@ -59,7 +59,32 @@ class GenStreamError(str):
|
|||
"Error:" by checking isinstance(chunk, GenStreamError).
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
__slots__ = ("public",)
|
||||
|
||||
def __new__(
|
||||
cls,
|
||||
value,
|
||||
*,
|
||||
public: bool = False,
|
||||
):
|
||||
obj = str.__new__(cls, value)
|
||||
obj.public = bool(public)
|
||||
return obj
|
||||
|
||||
|
||||
class GenStreamErrorRaised(RuntimeError):
|
||||
"""Internal exception form of ``GenStreamError`` for generator boundaries."""
|
||||
|
||||
__slots__ = ("public",)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
value,
|
||||
*,
|
||||
public: bool = False,
|
||||
):
|
||||
super().__init__(value)
|
||||
self.public = bool(public)
|
||||
|
||||
|
||||
class InferenceOrchestrator:
|
||||
|
|
@ -531,13 +556,19 @@ class InferenceOrchestrator:
|
|||
initial_resp_queue = self._resp_queue
|
||||
while True:
|
||||
if self._proc is not initial_proc or self._resp_queue is not initial_resp_queue:
|
||||
yield GenStreamError(f"Error: {self._subprocess_crash_message(crash_context)}")
|
||||
yield GenStreamError(
|
||||
f"Error: {self._subprocess_crash_message(crash_context)}",
|
||||
public = True,
|
||||
)
|
||||
return
|
||||
resp = read_one(read_timeout)
|
||||
if resp is None:
|
||||
# Check subprocess health
|
||||
if not self._ensure_subprocess_alive():
|
||||
yield GenStreamError(f"Error: {self._subprocess_crash_message(crash_context)}")
|
||||
yield GenStreamError(
|
||||
f"Error: {self._subprocess_crash_message(crash_context)}",
|
||||
public = True,
|
||||
)
|
||||
return
|
||||
continue
|
||||
|
||||
|
|
@ -689,11 +720,11 @@ class InferenceOrchestrator:
|
|||
GPU work stays serialized; this only avoids orchestrator lock contention.
|
||||
"""
|
||||
if not self._ensure_subprocess_alive():
|
||||
yield GenStreamError("Error: Inference subprocess is not running")
|
||||
yield GenStreamError("Error: Inference subprocess is not running", public = True)
|
||||
return
|
||||
|
||||
if not self.active_model_name:
|
||||
yield GenStreamError("Error: No active model")
|
||||
yield GenStreamError("Error: No active model", public = True)
|
||||
return
|
||||
# Latch the target model so the recheck below can detect a switch that completed
|
||||
# between _start_dispatcher and mailbox registration (mirrors the locked path's
|
||||
|
|
@ -704,7 +735,7 @@ class InferenceOrchestrator:
|
|||
# so without this early-out a compare request would enqueue a generate on the
|
||||
# outgoing model and delay the switch.
|
||||
if self._unload_pending:
|
||||
yield GenStreamError("Error: model is being unloaded")
|
||||
yield GenStreamError("Error: model is being unloaded", public = True)
|
||||
return
|
||||
|
||||
# Ensure the dispatcher runs. _start_dispatcher serializes concurrent starters under
|
||||
|
|
@ -776,7 +807,7 @@ class InferenceOrchestrator:
|
|||
# _stop_dispatcher joins the dispatcher, which itself takes that lock.
|
||||
if orphaned_dispatcher:
|
||||
self._stop_dispatcher()
|
||||
yield GenStreamError("Error: model is being unloaded")
|
||||
yield GenStreamError("Error: model is being unloaded", public = True)
|
||||
return
|
||||
|
||||
try:
|
||||
|
|
@ -1376,6 +1407,7 @@ class InferenceOrchestrator:
|
|||
use_adapter: Optional[Union[bool, str]] = None,
|
||||
stats_holder: Optional[dict] = None,
|
||||
presence_penalty: float = 0.0,
|
||||
reasoning_prefilled: bool = False,
|
||||
**_unused,
|
||||
):
|
||||
"""Run the safetensors agentic tool loop in the parent process,
|
||||
|
|
@ -1414,12 +1446,27 @@ class InferenceOrchestrator:
|
|||
presence_penalty = presence_penalty,
|
||||
)
|
||||
if use_adapter is not None:
|
||||
yield from self.generate_with_adapter_control(
|
||||
stream = self.generate_with_adapter_control(
|
||||
use_adapter = use_adapter,
|
||||
**common_kwargs,
|
||||
)
|
||||
else:
|
||||
yield from self.generate_chat_response(**common_kwargs)
|
||||
stream = self.generate_chat_response(**common_kwargs)
|
||||
close_stream = False
|
||||
try:
|
||||
for chunk in stream:
|
||||
if isinstance(chunk, GenStreamError):
|
||||
close_stream = True
|
||||
raise GenStreamErrorRaised(str(chunk), public = chunk.public)
|
||||
yield chunk
|
||||
finally:
|
||||
if close_stream:
|
||||
close = getattr(stream, "close", None)
|
||||
if callable(close):
|
||||
try:
|
||||
close()
|
||||
except Exception:
|
||||
logger.debug("failed to close errored generation stream", exc_info = True)
|
||||
|
||||
initial = list(messages)
|
||||
if system_prompt:
|
||||
|
|
@ -1441,6 +1488,7 @@ class InferenceOrchestrator:
|
|||
confirm_tool_calls = confirm_tool_calls,
|
||||
bypass_permissions = bypass_permissions,
|
||||
permission_mode = permission_mode,
|
||||
reasoning_prefilled = reasoning_prefilled,
|
||||
)
|
||||
|
||||
def generate_with_adapter_control(
|
||||
|
|
@ -1454,14 +1502,27 @@ class InferenceOrchestrator:
|
|||
|
||||
Uses the dispatcher path (no _gen_lock) so compare-mode requests
|
||||
don't block each other; the subprocess serializes them via its
|
||||
sequential command loop.
|
||||
sequential command loop. Backend failures raise instead of becoming
|
||||
assistant text.
|
||||
"""
|
||||
yield from self._generate_dispatched(
|
||||
stream = self._generate_dispatched(
|
||||
use_adapter = use_adapter,
|
||||
cancel_event = cancel_event,
|
||||
stats_holder = stats_holder,
|
||||
**gen_kwargs,
|
||||
)
|
||||
try:
|
||||
for chunk in stream:
|
||||
if isinstance(chunk, GenStreamError):
|
||||
# Preserve the public/operational flag so the route can surface
|
||||
# the real message (e.g. "model is being unloaded") instead of a
|
||||
# generic error. Mirrors the safetensors tool loop's _single_turn.
|
||||
raise GenStreamErrorRaised(str(chunk), public = chunk.public)
|
||||
yield chunk
|
||||
finally:
|
||||
close = getattr(stream, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
|
||||
def _generate_inner(
|
||||
self,
|
||||
|
|
@ -1489,11 +1550,11 @@ class InferenceOrchestrator:
|
|||
readers don't consume each other's tokens off the shared resp_queue.
|
||||
"""
|
||||
if not self._ensure_subprocess_alive():
|
||||
yield GenStreamError("Error: Inference subprocess is not running")
|
||||
yield GenStreamError("Error: Inference subprocess is not running", public = True)
|
||||
return
|
||||
|
||||
if not self.active_model_name:
|
||||
yield GenStreamError("Error: No active model")
|
||||
yield GenStreamError("Error: No active model", public = True)
|
||||
return
|
||||
expected_model = self.active_model_name
|
||||
|
||||
|
|
@ -1510,7 +1571,7 @@ class InferenceOrchestrator:
|
|||
# so we never generate on the wrong one.
|
||||
if self._unload_pending or self.active_model_name != expected_model:
|
||||
# Won the lock handoff during a switch; don't start on the outgoing model.
|
||||
yield GenStreamError("Error: model is being unloaded")
|
||||
yield GenStreamError("Error: model is being unloaded", public = True)
|
||||
return
|
||||
request_id = str(uuid.uuid4())
|
||||
image_b64 = self._pil_to_base64(image) if image is not None else None
|
||||
|
|
@ -1695,10 +1756,10 @@ class InferenceOrchestrator:
|
|||
) -> Generator[str, None, None]:
|
||||
"""Shared inner logic for audio input generation (Whisper + ASR)."""
|
||||
if not self._ensure_subprocess_alive():
|
||||
yield GenStreamError("Error: Inference subprocess is not running")
|
||||
yield GenStreamError("Error: Inference subprocess is not running", public = True)
|
||||
return
|
||||
if not self.active_model_name:
|
||||
yield GenStreamError("Error: No active model")
|
||||
yield GenStreamError("Error: No active model", public = True)
|
||||
return
|
||||
expected_model = self.active_model_name
|
||||
|
||||
|
|
@ -1707,7 +1768,7 @@ class InferenceOrchestrator:
|
|||
# cleared or swapped the model while we waited.
|
||||
if self._unload_pending or self.active_model_name != expected_model:
|
||||
# Won the lock handoff during a switch; don't start on the outgoing model.
|
||||
yield GenStreamError("Error: model is being unloaded")
|
||||
yield GenStreamError("Error: model is being unloaded", public = True)
|
||||
return
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
With server-side tools disabled (``unsloth run --disable-tools``, every
|
||||
``unsloth start`` coding agent), requests carrying the client's own ``tools``
|
||||
bypass Studio's tool loop and are relayed to/from llama-server verbatim. Small
|
||||
bypass Unsloth's tool loop and are relayed to/from llama-server verbatim. Small
|
||||
GGUF models often emit their tool calls as TEXT (``<tool_call>{...}</tool_call>``,
|
||||
Gemma ``<|tool_call>...``, ``<function=...>`` XML) instead of structured
|
||||
``tool_calls`` -- on the passthrough that text reaches the agent as prose and
|
||||
|
|
@ -18,7 +18,7 @@ promotes calls whose function name exactly matches a declared tool. Promotion
|
|||
removes EXACTLY the promoted calls' markup spans (the parser reports them):
|
||||
undeclared calls, unparseable blocks, and suppressed alternate formats keep
|
||||
every byte and relay as text, so healing can never silently delete model
|
||||
output. Responses without a tool signal, requests without tools, and Studio's
|
||||
output. Responses without a tool signal, requests without tools, and Unsloth's
|
||||
own enable-tools loop are untouched. Per-request opt-out:
|
||||
``auto_heal_tool_calls: false``. Process kill-switch:
|
||||
``UNSLOTH_DISABLE_TOOL_CALL_HEALING=1``.
|
||||
|
|
|
|||
|
|
@ -122,12 +122,12 @@ def calculate_cost(provider: str, model: str, usage: dict[str, Any]) -> dict[str
|
|||
"priced": bool(prices),
|
||||
}
|
||||
|
||||
# Accept raw (input_tokens/output_tokens) and Studio chat-style
|
||||
# Accept raw (input_tokens/output_tokens) and Unsloth chat-style
|
||||
# (prompt_tokens/completion_tokens) envelopes. Cache buckets differ:
|
||||
# raw Anthropic: input_tokens EXCLUDES cache buckets
|
||||
# raw OpenAI: input_tokens INCLUDES cache_read
|
||||
# Studio Anthropic: prompt_tokens INCLUDES cache_creation + cache_read
|
||||
# Studio OpenAI: prompt_tokens == raw input_tokens
|
||||
# Unsloth Anthropic: prompt_tokens INCLUDES cache_creation + cache_read
|
||||
# Unsloth OpenAI: prompt_tokens == raw input_tokens
|
||||
# Clamp >=0 so corrupted payloads can't produce a negative bill.
|
||||
cache_creation = max(0, int(usage.get("cache_creation_input_tokens") or 0))
|
||||
cache_read_native_present = (
|
||||
|
|
@ -160,7 +160,7 @@ def calculate_cost(provider: str, model: str, usage: dict[str, Any]) -> dict[str
|
|||
output_tokens = max(0, int(usage.get("completion_tokens") or 0))
|
||||
if provider == "openai":
|
||||
# Cached tokens land on input_tokens_details (raw Responses) or
|
||||
# prompt_tokens_details (Studio chat-style).
|
||||
# prompt_tokens_details (Unsloth chat-style).
|
||||
for key in ("input_tokens_details", "prompt_tokens_details"):
|
||||
details = usage.get(key) or {}
|
||||
if isinstance(details, dict):
|
||||
|
|
|
|||
|
|
@ -276,8 +276,9 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
|
|||
"auth_header": "Authorization",
|
||||
"auth_prefix": "Bearer ",
|
||||
"notes": (
|
||||
"Local Ollama server. OpenAI-compatible /v1/chat/completions; "
|
||||
"no API key. Surfaced via CUSTOM_PROVIDER_PRESETS in the frontend."
|
||||
"Ollama server (local or cloud). OpenAI-compatible "
|
||||
"/v1/chat/completions; API key optional (required by Ollama "
|
||||
"cloud). Surfaced via CUSTOM_PROVIDER_PRESETS in the frontend."
|
||||
),
|
||||
"hidden": True,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ from core.inference.tool_call_parser import (
|
|||
# pattern lists, so the safetensors streaming strip stays aligned with the parser.
|
||||
from core.tool_healing import (
|
||||
_REHEARSAL_TAIL_STRIP_RE,
|
||||
_THINK_CLOSE_RE,
|
||||
_strip_bracket_tag_calls,
|
||||
_think_spans_outside_tool_markup,
|
||||
apply_tool_strip_patterns,
|
||||
|
|
@ -57,6 +58,7 @@ from core.tool_healing import (
|
|||
)
|
||||
from core.inference.tool_loop_controller import (
|
||||
ToolLoopController,
|
||||
append_deferred_nudges,
|
||||
coerce_tool_arguments,
|
||||
status_for_tool,
|
||||
tool_event_provenance,
|
||||
|
|
@ -303,6 +305,45 @@ def _status_for_tool(tool_name: str, arguments: dict) -> str:
|
|||
return status_for_tool(tool_name, arguments)
|
||||
|
||||
|
||||
def _reprompt_intent_text(text: str, *, reasoning_prefilled: bool = False) -> str:
|
||||
"""Return visible answer text for the plan-without-action classifier.
|
||||
|
||||
Safetensors reasoning shares the cumulative text channel with the answer.
|
||||
Forward-looking phrases inside ``<think>`` / ``[THINK]`` are private
|
||||
planning, not a user-visible promise to call a tool. Match GGUF's behavior:
|
||||
classify visible content when present and fall back to reasoning only for a
|
||||
reasoning-only stall.
|
||||
"""
|
||||
prefilled_reasoning = ""
|
||||
if reasoning_prefilled:
|
||||
close = _THINK_CLOSE_RE.search(text)
|
||||
if close is None:
|
||||
return text.strip()
|
||||
prefilled_reasoning = text[: close.end()].strip()
|
||||
text = text[close.end() :].strip()
|
||||
if not text:
|
||||
return prefilled_reasoning
|
||||
|
||||
spans = _think_spans_outside_tool_markup(text)
|
||||
if not spans:
|
||||
return text.strip()
|
||||
|
||||
visible: list[str] = []
|
||||
reasoning: list[str] = []
|
||||
cursor = 0
|
||||
for start, end in spans:
|
||||
visible.append(text[cursor:start])
|
||||
reasoning.append(text[start:end])
|
||||
cursor = end
|
||||
visible.append(text[cursor:])
|
||||
|
||||
visible_text = "".join(visible).strip()
|
||||
reasoning_text = "".join(reasoning).strip()
|
||||
if visible_text:
|
||||
return visible_text
|
||||
return "\n".join(part for part in (prefilled_reasoning, reasoning_text) if part).strip()
|
||||
|
||||
|
||||
def _looks_like_enabled_bare_json(text: str, enabled_tool_names: Optional[set]) -> bool:
|
||||
"""True when ``text`` opens with an ENABLED markerless bare-JSON call; an ordinary JSON answer returns False."""
|
||||
probe = strip_llama3_leading_sentinels(text.lstrip())
|
||||
|
|
@ -447,6 +488,7 @@ def run_safetensors_tool_loop(
|
|||
confirm_tool_calls: bool = False,
|
||||
bypass_permissions: bool = False,
|
||||
permission_mode: Optional[str] = None,
|
||||
reasoning_prefilled: bool = False,
|
||||
) -> Generator[dict, None, None]:
|
||||
"""Drive an agentic tool loop on top of a cumulative-text generator.
|
||||
|
||||
|
|
@ -953,9 +995,12 @@ def run_safetensors_tool_loop(
|
|||
if not safety_tc:
|
||||
# Re-prompt once on plan-without-action, before any tool runs
|
||||
# (GGUF loop parity). The retry is gated on nudge_tool_calls so
|
||||
# Studio callers (which send True) always nudge, while API callers
|
||||
# Unsloth callers (which send True) always nudge, while API callers
|
||||
# who omit the flag keep today's no-reprompt behavior (opt-in).
|
||||
stripped_answer = content_accum.strip()
|
||||
intent_text = _reprompt_intent_text(
|
||||
content_accum,
|
||||
reasoning_prefilled = reasoning_prefilled,
|
||||
)
|
||||
if (
|
||||
auto_heal_tool_calls
|
||||
and nudge_tool_calls
|
||||
|
|
@ -964,7 +1009,7 @@ def run_safetensors_tool_loop(
|
|||
and not rag_autoinjected
|
||||
and not tool_denied
|
||||
and not any(record.executed for record in tool_controller.history)
|
||||
and is_short_intent_without_action(stripped_answer)
|
||||
and is_short_intent_without_action(intent_text)
|
||||
):
|
||||
reprompt_count += 1
|
||||
logger.info(
|
||||
|
|
@ -972,9 +1017,9 @@ def run_safetensors_tool_loop(
|
|||
"calling tools (%d chars)",
|
||||
reprompt_count,
|
||||
MAX_ACT_REPROMPTS,
|
||||
len(stripped_answer),
|
||||
len(intent_text),
|
||||
)
|
||||
conversation.append({"role": "assistant", "content": stripped_answer})
|
||||
conversation.append({"role": "assistant", "content": intent_text})
|
||||
tool_hint = " or ".join(_active_tool_names(active_tools)) or "an available tool"
|
||||
conversation.append(
|
||||
{
|
||||
|
|
@ -1099,6 +1144,9 @@ def run_safetensors_tool_loop(
|
|||
|
||||
assistant_msg: dict = {"role": "assistant", "content": content_text}
|
||||
assistant_appended = False
|
||||
# Collect no-op nudges and flush them after the batch, so a no-op doesn't
|
||||
# abort it and drop the parallel calls that follow.
|
||||
deferred_noop_msgs: list = []
|
||||
|
||||
for tc in tool_calls or []:
|
||||
func = tc.get("function", {}) or {}
|
||||
|
|
@ -1127,12 +1175,12 @@ def run_safetensors_tool_loop(
|
|||
"provenance": decision.provenance,
|
||||
}
|
||||
completion = tool_controller.record_noop(decision)
|
||||
conversation.append(completion.model_message())
|
||||
deferred_noop_msgs.append(completion.model_message())
|
||||
logger.info(
|
||||
"Suppressed local safetensors tool call as internal no-op: "
|
||||
f"action={decision.action} tool={decision.tool_name}"
|
||||
)
|
||||
break
|
||||
continue
|
||||
|
||||
if not assistant_appended:
|
||||
assistant_msg["tool_calls"] = [decision.as_assistant_tool_call()]
|
||||
|
|
@ -1243,6 +1291,8 @@ def run_safetensors_tool_loop(
|
|||
yield completion.tool_end_event()
|
||||
conversation.append(completion.tool_message())
|
||||
|
||||
append_deferred_nudges(conversation, deferred_noop_msgs)
|
||||
|
||||
# Clear the status badge before the next turn.
|
||||
yield {"type": "status", "text": ""}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"""Sandbox-side compatibility shim for ChatGPT code-interpreter paths.
|
||||
|
||||
Models habitually write to /mnt/data (or /mnt/outputs, /home/sandbox,
|
||||
/workspace), none of which exist in the Studio sandbox. This module sits on the
|
||||
/workspace), none of which exist in the Unsloth sandbox. This module sits on the
|
||||
sandbox subprocess PYTHONPATH (see ``tools._build_safe_env``), so it loads at
|
||||
interpreter startup in every sandboxed ``python`` run and any Python the
|
||||
``terminal`` tool launches.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Shared controller state for Studio local agentic tool loops.
|
||||
"""Shared controller state for Unsloth local agentic tool loops.
|
||||
|
||||
This module is intentionally dependency-light: it owns only per-response
|
||||
ledger state and value objects used by the GGUF and safetensors loops.
|
||||
|
|
@ -266,6 +266,17 @@ def strip_result_for_model(result: str) -> str:
|
|||
return result
|
||||
|
||||
|
||||
def append_deferred_nudges(conversation: list, msgs: Sequence[dict]) -> None:
|
||||
"""Append a batch's no-op nudges as one deduped ``role=user`` message.
|
||||
|
||||
Deferred to after the batch's tool results so a no-op never splits an
|
||||
assistant's ``tool_calls`` from their ``role=tool`` results.
|
||||
"""
|
||||
contents = list(dict.fromkeys(msg["content"] for msg in msgs))
|
||||
if contents:
|
||||
conversation.append({"role": "user", "content": "\n\n".join(contents)})
|
||||
|
||||
|
||||
def _tool_name_from_schema(tool: Mapping[str, Any]) -> str:
|
||||
function = tool.get("function")
|
||||
if not isinstance(function, Mapping):
|
||||
|
|
@ -277,8 +288,9 @@ def _tool_name_from_schema(tool: Mapping[str, Any]) -> str:
|
|||
def _noop_result(reason: NoopReason, tool_name: str) -> str:
|
||||
if reason == "duplicate":
|
||||
return (
|
||||
"The previous tool request was not executed because this exact "
|
||||
"tool call already completed successfully. Do not repeat the same "
|
||||
f"One earlier request to call tool '{tool_name}' in this batch was "
|
||||
"not executed because an identical call had already completed "
|
||||
"successfully. Do not repeat the same "
|
||||
"tool call. Continue with a different enabled tool if that would "
|
||||
"materially help, or provide the final answer if you have enough "
|
||||
"information."
|
||||
|
|
@ -291,8 +303,8 @@ def _noop_result(reason: NoopReason, tool_name: str) -> str:
|
|||
"the requested final note or answer."
|
||||
)
|
||||
return (
|
||||
f"The previous tool request was not executed because tool "
|
||||
f"'{tool_name}' is not enabled for this request. Provide the "
|
||||
f"One earlier request to call tool '{tool_name}' in this batch was "
|
||||
"not executed because that tool is not enabled for this request. Provide the "
|
||||
"final answer now without calling more tools."
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -2502,7 +2502,7 @@ def _build_safe_env(workdir: str) -> dict[str, str]:
|
|||
shim directory.
|
||||
"""
|
||||
# Start from the running interpreter's dir so 'python'/'pip' resolve to the
|
||||
# same environment the Studio server runs in.
|
||||
# same environment the Unsloth server runs in.
|
||||
exe_dir = os.path.dirname(sys.executable)
|
||||
path_entries = [exe_dir] if exe_dir else []
|
||||
|
||||
|
|
@ -2792,7 +2792,7 @@ def _bypass_preexec():
|
|||
"""Minimal pre-exec for bypass exec: os.setsid() only.
|
||||
|
||||
Required, not a restriction: _kill_process_tree does killpg(getpgid(child)),
|
||||
so without a new session a timeout/cancel would kill the Studio server too.
|
||||
so without a new session a timeout/cancel would kill the Unsloth server too.
|
||||
"""
|
||||
try:
|
||||
os.setsid()
|
||||
|
|
@ -2800,13 +2800,13 @@ def _bypass_preexec():
|
|||
pass
|
||||
|
||||
|
||||
# Hardening the Studio parent is done once (PR_SET_DUMPABLE is process-global
|
||||
# Hardening the Unsloth parent is done once (PR_SET_DUMPABLE is process-global
|
||||
# and sticky); guarded so repeated bypass calls do not re-issue the prctl.
|
||||
_parent_proc_hardened = False
|
||||
|
||||
|
||||
def _harden_parent_against_proc_env_leak() -> bool:
|
||||
"""Make the Studio process's /proc/<pid>/environ unreadable to its children.
|
||||
"""Make the Unsloth process's /proc/<pid>/environ unreadable to its children.
|
||||
|
||||
Stripping the child env is not enough on Linux: a bypassed same-UID child
|
||||
can read /proc/<getppid()>/environ to recover the parent's unfiltered
|
||||
|
|
@ -3600,14 +3600,18 @@ _MAX_PAGE_CHARS = 16000 # cap fetched page text (after HTML-to-MD conversion)
|
|||
# Raw download cap > _MAX_PAGE_CHARS since SSR pages embed large <head> sections
|
||||
# stripped during conversion; 512 KB still reaches article content.
|
||||
_MAX_FETCH_BYTES = 512 * 1024
|
||||
# PDF cross-reference data lives at EOF, so extraction needs the whole body.
|
||||
_MAX_PDF_FETCH_BYTES = 10 * 1024 * 1024
|
||||
_MAX_WEB_PDF_PAGES = 50
|
||||
# Control/undecodable chars, excluding text whitespace and ESC (for ANSI logs).
|
||||
# Binary when they exceed 12.5%, after allowing 16 minor encoding glitches.
|
||||
_BINARY_CHAR_RE = re.compile("[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1a\\x1c-\\x1f\\x7f-\\x9f\\ufffd]")
|
||||
_MIN_BINARY_CHARS = 16
|
||||
_BINARY_CHAR_DIVISOR = 8
|
||||
# Common binary signatures that can otherwise look text-heavy when mislabeled.
|
||||
_PDF_MAGIC = b"%PDF-"
|
||||
_BINARY_MAGIC = (
|
||||
b"%PDF-", # PDF
|
||||
_PDF_MAGIC,
|
||||
b"PK\x03\x04", # zip / docx / xlsx / pptx / epub / jar
|
||||
b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", # OLE / legacy Office
|
||||
b"\x89PNG\r\n\x1a\n", # PNG
|
||||
|
|
@ -3641,14 +3645,22 @@ def _looks_binary(text: str) -> bool:
|
|||
)
|
||||
|
||||
|
||||
def _has_binary_magic(data: bytes) -> bool:
|
||||
"""Whether a common binary signature follows optional BOM or whitespace."""
|
||||
def _magic_head(data: bytes) -> bytes:
|
||||
head = data[:1024].lstrip()
|
||||
for bom, _codec in _UNICODE_BOM_CODECS:
|
||||
if head.startswith(bom):
|
||||
head = head.removeprefix(bom).lstrip()
|
||||
break
|
||||
return head.startswith(_BINARY_MAGIC)
|
||||
return head
|
||||
|
||||
|
||||
def _has_pdf_magic(data: bytes) -> bool:
|
||||
return _magic_head(data).startswith(_PDF_MAGIC)
|
||||
|
||||
|
||||
def _has_binary_magic(data: bytes) -> bool:
|
||||
"""Whether a common binary signature follows optional BOM or whitespace."""
|
||||
return _magic_head(data).startswith(_BINARY_MAGIC)
|
||||
|
||||
|
||||
def _has_single_byte_text_evidence(data: bytes) -> bool:
|
||||
|
|
@ -3659,6 +3671,45 @@ def _has_single_byte_text_evidence(data: bytes) -> bool:
|
|||
return ascii_text_bytes / len(data) >= _MIN_SINGLE_BYTE_ASCII_RATIO
|
||||
|
||||
|
||||
def _extract_pdf_text(data: bytes) -> str:
|
||||
"""Extract page-delimited text with the same parser used by RAG ingestion."""
|
||||
from ..rag.parsers import parse_pdf_bytes
|
||||
|
||||
pages, total_pages = parse_pdf_bytes(data, max_pages = _MAX_WEB_PDF_PAGES)
|
||||
page_limit_reached = total_pages > _MAX_WEB_PDF_PAGES
|
||||
parts: list[str] = []
|
||||
length = 0
|
||||
text_limited = False
|
||||
for page in pages:
|
||||
page_text = page.text.strip()
|
||||
if not page_text:
|
||||
continue
|
||||
section = f"## Page {page.page_number}\n\n{page_text}"
|
||||
piece = ("\n\n" if parts else "") + section
|
||||
remaining = _MAX_PAGE_CHARS - length
|
||||
if len(piece) > remaining:
|
||||
parts.append(piece[:remaining])
|
||||
text_limited = True
|
||||
break
|
||||
parts.append(piece)
|
||||
length += len(piece)
|
||||
|
||||
text = "".join(parts).rstrip()
|
||||
if not text:
|
||||
if page_limit_reached:
|
||||
return f"(PDF contains no extractable text in the first {_MAX_WEB_PDF_PAGES} pages)"
|
||||
return ""
|
||||
limits = []
|
||||
if text_limited:
|
||||
limits.append(f"text limited to {_MAX_PAGE_CHARS:,} characters")
|
||||
if page_limit_reached:
|
||||
limits.append(f"page processing capped at {_MAX_WEB_PDF_PAGES} pages")
|
||||
if limits:
|
||||
marker = f"\n\n... (PDF extraction {'; '.join(limits)})"
|
||||
text = text[: _MAX_PAGE_CHARS - len(marker)].rstrip() + marker
|
||||
return text
|
||||
|
||||
|
||||
_USER_AGENTS = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
|
|
@ -4054,29 +4105,71 @@ def _fetch_url_raw(
|
|||
return reason2, "", ""
|
||||
current_host = rp.hostname
|
||||
continue
|
||||
|
||||
# get_content_type() defaults to "text/plain" when the header is
|
||||
# absent (RFC 2045); report "" instead so callers can tell a missing
|
||||
# header apart from a server that really declared text/plain.
|
||||
if resp.headers.get("Content-Type") is None:
|
||||
content_type = ""
|
||||
else:
|
||||
content_type = (resp.headers.get_content_type() or "").lower()
|
||||
|
||||
# Success: read the capped body enforcing the budget between chunks
|
||||
# (see _read_capped_body), so a slow-drip server can't stretch a
|
||||
# single resp.read past the deadline.
|
||||
declared_pdf = content_type == "application/pdf"
|
||||
read_limit = _MAX_PDF_FETCH_BYTES + 1 if declared_pdf else max_bytes
|
||||
body_error, raw_bytes = _read_capped_body(
|
||||
resp,
|
||||
max_bytes,
|
||||
read_limit,
|
||||
timeout,
|
||||
deadline,
|
||||
cancel_event,
|
||||
)
|
||||
if body_error is not None:
|
||||
return body_error, "", ""
|
||||
|
||||
# A missing or wrong PDF MIME type is common: once the initial text-sized
|
||||
# read identifies PDF magic, finish the bounded download to reach the EOF xref.
|
||||
if not declared_pdf and len(raw_bytes) == max_bytes and _has_pdf_magic(raw_bytes):
|
||||
tail_error, tail = _read_capped_body(
|
||||
resp,
|
||||
_MAX_PDF_FETCH_BYTES - max_bytes + 1,
|
||||
timeout,
|
||||
deadline,
|
||||
cancel_event,
|
||||
)
|
||||
if tail_error is not None:
|
||||
return tail_error, "", ""
|
||||
raw_bytes += tail
|
||||
break
|
||||
else:
|
||||
return "Failed to fetch URL: too many redirects.", "", ""
|
||||
|
||||
# get_content_type() defaults to "text/plain" when the header is
|
||||
# absent (RFC 2045); report "" instead so callers can tell a missing
|
||||
# header apart from a server that really declared text/plain.
|
||||
if resp.headers.get("Content-Type") is None:
|
||||
content_type = ""
|
||||
else:
|
||||
content_type = (resp.headers.get_content_type() or "").lower()
|
||||
is_pdf = declared_pdf or _has_pdf_magic(raw_bytes)
|
||||
if is_pdf:
|
||||
if len(raw_bytes) > _MAX_PDF_FETCH_BYTES:
|
||||
return (
|
||||
"(PDF content exceeds the download limit; not readable as text)",
|
||||
"",
|
||||
content_type,
|
||||
)
|
||||
budget_error = _fetch_budget_exceeded(deadline, cancel_event)
|
||||
if budget_error is not None:
|
||||
return budget_error, "", content_type
|
||||
try:
|
||||
pdf_text = _extract_pdf_text(raw_bytes)
|
||||
except Exception as exc:
|
||||
logger.debug("web PDF text extraction failed (%s)", type(exc).__name__)
|
||||
return "(PDF content could not be read as text)", "", content_type
|
||||
budget_error = _fetch_budget_exceeded(deadline, cancel_event)
|
||||
if budget_error is not None:
|
||||
return budget_error, "", content_type
|
||||
if not pdf_text:
|
||||
pdf_text = "(PDF contains no extractable text)"
|
||||
# Report the true type even for a mislabeled body so the caller's "html"
|
||||
# check routes the extracted text to the plain-text path, not html_to_markdown.
|
||||
return None, pdf_text, "application/pdf"
|
||||
|
||||
# Reject known-binary MIME types before decoding. Binary is returned as the
|
||||
# error string so the caller surfaces the placeholder, not replacement chars.
|
||||
|
|
@ -5389,7 +5482,7 @@ def _truncate(text: str, limit: int = _MAX_OUTPUT_CHARS) -> str:
|
|||
|
||||
|
||||
# ChatGPT code-interpreter path conventions models write out of habit; none
|
||||
# exist in the Studio sandbox, so a failure on one earns the retry hint.
|
||||
# exist in the Unsloth sandbox, so a failure on one earns the retry hint.
|
||||
_MISSING_PATH_PREFIXES = (
|
||||
"/mnt/data",
|
||||
"/mnt/outputs",
|
||||
|
|
@ -5595,7 +5688,7 @@ def _python_exec(
|
|||
# Close the /proc/<parent>/environ secret-recovery path first; if it
|
||||
# cannot be applied, fail closed rather than leak the parent environ.
|
||||
return (
|
||||
"Execution error: could not harden the Studio process against "
|
||||
"Execution error: could not harden the Unsloth process against "
|
||||
"/proc environment reads; refusing bypass execution."
|
||||
)
|
||||
|
||||
|
|
@ -5740,7 +5833,7 @@ def _bash_exec(
|
|||
# Close the /proc/<parent>/environ secret-recovery path first; if it
|
||||
# cannot be applied, fail closed rather than leak the parent environ.
|
||||
return (
|
||||
"Execution error: could not harden the Studio process against "
|
||||
"Execution error: could not harden the Unsloth process against "
|
||||
"/proc environment reads; refusing bypass execution."
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -513,20 +513,25 @@ def _handle_generate(backend, cmd: dict, resp_queue: Any, cancel_event) -> None:
|
|||
|
||||
logger.info("Starting text generation for request_id=%s", request_id)
|
||||
|
||||
for cumulative_text in generator:
|
||||
# cancel_event is an mp.Event — checked instantly, no queue polling.
|
||||
if cancel_event.is_set():
|
||||
logger.info("Generation cancelled for request %s", request_id)
|
||||
break
|
||||
try:
|
||||
for cumulative_text in generator:
|
||||
# cancel_event is an mp.Event — checked instantly, no queue polling.
|
||||
if cancel_event.is_set():
|
||||
logger.info("Generation cancelled for request %s", request_id)
|
||||
break
|
||||
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "token",
|
||||
"request_id": request_id,
|
||||
"text": cumulative_text,
|
||||
},
|
||||
)
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "token",
|
||||
"request_id": request_id,
|
||||
"text": cumulative_text,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
close = getattr(generator, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
|
||||
_send_response(
|
||||
resp_queue,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
Both turn pixels into indexable text and are a no-op (never raise) without a loaded
|
||||
vision model. They reuse the chat model's vision endpoint, so it must be served with
|
||||
``--ubatch-size`` >= one image's tokens (some encoders, e.g. Gemma, attend
|
||||
non-causally and abort otherwise); Studio's vision chat already requires this."""
|
||||
non-causally and abort otherwise); Unsloth's vision chat already requires this."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,22 @@ def _names_gguf(model: str) -> bool:
|
|||
return "gguf" in re.split(r"[^a-z0-9]+", model.lower())
|
||||
|
||||
|
||||
def gguf_repo_for_embedding_model(model: str) -> str:
|
||||
"""GGUF repo for ``model``, honoring an explicit companion override."""
|
||||
if "RAG_EMBED_GGUF_REPO" in os.environ:
|
||||
return EMBED_GGUF_REPO
|
||||
if model == DEFAULT_EMBEDDING_MODEL:
|
||||
return EMBED_GGUF_REPO
|
||||
if _names_gguf(model):
|
||||
return model
|
||||
return f"{model}-GGUF"
|
||||
|
||||
|
||||
def default_gguf_repo() -> str:
|
||||
"""GGUF companion for the env/default embedding model."""
|
||||
return gguf_repo_for_embedding_model(EMBEDDING_MODEL)
|
||||
|
||||
|
||||
def effective_gguf_repo() -> str:
|
||||
"""GGUF repo for the llama-server backend, tracking the effective model.
|
||||
|
||||
|
|
@ -95,14 +111,7 @@ def effective_gguf_repo() -> str:
|
|||
``-GGUF`` companion repo (the unsloth convention the default pair follows),
|
||||
or is used as-is when it already names a GGUF repo.
|
||||
"""
|
||||
if "RAG_EMBED_GGUF_REPO" in os.environ:
|
||||
return EMBED_GGUF_REPO
|
||||
model = effective_embedding_model()
|
||||
if model == DEFAULT_EMBEDDING_MODEL:
|
||||
return EMBED_GGUF_REPO
|
||||
if _names_gguf(model):
|
||||
return model
|
||||
return f"{model}-GGUF"
|
||||
return gguf_repo_for_embedding_model(effective_embedding_model())
|
||||
|
||||
|
||||
# llama-server backend only. F16 over Q8_0: faster (no per-block dequant for this
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ Opt-in (``RAG_EMBED_BACKEND=llama-server``). Runs a dedicated
|
|||
Device is ``auto`` (GPU when present, else CPU, falling back to CPU if a GPU start
|
||||
fails); ``RAG_EMBED_DEVICE`` forces it. We call only llama_cpp's *static* helpers
|
||||
(no torch), copying the instance-coupled bits locally, since constructing a
|
||||
``LlamaCppBackend`` runs an ``__init__`` reaper that kills any Studio llama-server
|
||||
``LlamaCppBackend`` runs an ``__init__`` reaper that kills any Unsloth llama-server
|
||||
-- so each request re-spawns ours if it died (self-heal).
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ _model = None
|
|||
_name: str | None = None
|
||||
|
||||
|
||||
# Studio device -> torch device string. Apple has no torch device -> CPU.
|
||||
# Unsloth device -> torch device string. Apple has no torch device -> CPU.
|
||||
_TORCH_DEVICE = {DeviceType.CUDA: "cuda", DeviceType.XPU: "xpu"}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ def _markdown_incomplete(markdown: str, plain: str) -> bool:
|
|||
return markdown_letters < _PDF_INCOMPLETE_RATIO * plain_letters
|
||||
|
||||
|
||||
def _pdf_markdown(doc) -> list[str] | None:
|
||||
def _pdf_markdown(doc, pages: range | None = None) -> list[str] | None:
|
||||
"""Per-page layout-aware Markdown (tables, headings, lists) via pymupdf4llm; index
|
||||
i maps to page i+1. Returns None when the lib is missing, extraction fails, or the
|
||||
page count does not line up, so the caller falls back to plain PyMuPDF text."""
|
||||
|
|
@ -112,28 +112,44 @@ def _pdf_markdown(doc) -> list[str] | None:
|
|||
except Exception:
|
||||
return None
|
||||
try:
|
||||
chunks = pymupdf4llm.to_markdown(
|
||||
doc,
|
||||
page_chunks = True,
|
||||
show_progress = False,
|
||||
)
|
||||
kwargs = {"page_chunks": True, "show_progress": False}
|
||||
if pages is not None:
|
||||
kwargs["pages"] = list(pages)
|
||||
chunks = pymupdf4llm.to_markdown(doc, **kwargs)
|
||||
except Exception: # noqa: BLE001 - never let Markdown extraction break ingestion
|
||||
logger.warning("pymupdf4llm extraction failed; using plain text", exc_info = True)
|
||||
return None
|
||||
if not isinstance(chunks, list) or len(chunks) != doc.page_count:
|
||||
expected_pages = doc.page_count if pages is None else len(pages)
|
||||
if not isinstance(chunks, list) or len(chunks) != expected_pages:
|
||||
return None
|
||||
return [str(c.get("text") or "") for c in chunks]
|
||||
|
||||
|
||||
def _pdf(path: str, want_images: bool) -> tuple[list[Page], list[ParsedImage]]:
|
||||
def _pdf(
|
||||
source: str | bytes,
|
||||
want_images: bool,
|
||||
max_pages: int | None = None,
|
||||
) -> tuple[list[Page], list[ParsedImage], int]:
|
||||
import fitz # PyMuPDF
|
||||
|
||||
pages: list[Page] = []
|
||||
images: list[ParsedImage] = []
|
||||
doc = fitz.open(path)
|
||||
doc = (
|
||||
fitz.open(stream = source, filetype = "pdf") if isinstance(source, bytes) else fitz.open(source)
|
||||
)
|
||||
try:
|
||||
md = _pdf_markdown(doc) if config.PDF_MARKDOWN else None
|
||||
for i, page in enumerate(doc):
|
||||
if doc.needs_pass:
|
||||
raise ValueError("encrypted PDF requires a password")
|
||||
total_pages = doc.page_count
|
||||
page_numbers = range(total_pages if max_pages is None else min(total_pages, max_pages))
|
||||
if not config.PDF_MARKDOWN:
|
||||
md = None
|
||||
elif max_pages is None:
|
||||
md = _pdf_markdown(doc)
|
||||
else:
|
||||
md = _pdf_markdown(doc, page_numbers)
|
||||
for i, page_number in enumerate(page_numbers):
|
||||
page = doc[page_number]
|
||||
plain = page.get_text("text") or ""
|
||||
candidate = md[i] if md else ""
|
||||
# Prefer layout-aware Markdown (keeps tables/headings legible for retrieval),
|
||||
|
|
@ -147,7 +163,7 @@ def _pdf(path: str, want_images: bool) -> tuple[list[Page], list[ParsedImage]]:
|
|||
text = candidate
|
||||
else:
|
||||
text = plain
|
||||
pages.append(_page(text, i + 1))
|
||||
pages.append(_page(text, page_number + 1))
|
||||
if want_images:
|
||||
for img in page.get_images(full = True):
|
||||
xref = img[0]
|
||||
|
|
@ -161,13 +177,22 @@ def _pdf(path: str, want_images: bool) -> tuple[list[Page], list[ParsedImage]]:
|
|||
images.append(
|
||||
ParsedImage(
|
||||
image_bytes = image_bytes,
|
||||
page_number = i + 1,
|
||||
page_number = page_number + 1,
|
||||
xref = xref,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
doc.close()
|
||||
return pages, images
|
||||
return pages, images, total_pages
|
||||
|
||||
|
||||
def parse_pdf_bytes(data: bytes, *, max_pages: int | None = None) -> tuple[list[Page], int]:
|
||||
"""Extract PDF pages from an in-memory download using the ingestion parser.
|
||||
|
||||
Returns the (capped) pages plus the document's full page count, so a caller
|
||||
that set ``max_pages`` can tell a fully-read short PDF from a truncated one."""
|
||||
pages, _images, total_pages = _pdf(data, want_images = False, max_pages = max_pages)
|
||||
return pages, total_pages
|
||||
|
||||
|
||||
def _merge_rects(boxes: list) -> list:
|
||||
|
|
@ -416,7 +441,7 @@ def parse(path: str, *, want_images: bool = False):
|
|||
ext = os.path.splitext(path)[1].lower()
|
||||
|
||||
if ext == ".pdf":
|
||||
pages, images = _pdf(path, want_images)
|
||||
pages, images, _total = _pdf(path, want_images)
|
||||
return (pages, images) if want_images else pages
|
||||
|
||||
if ext == ".docx":
|
||||
|
|
|
|||
|
|
@ -158,6 +158,16 @@ def list_documents(conn: sqlite3.Connection, scope: str) -> list[dict]:
|
|||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def list_all_documents(conn: sqlite3.Connection) -> list[dict]:
|
||||
"""Every uploaded document across all scopes (KBs, threads, projects)."""
|
||||
rows = conn.execute(
|
||||
"SELECT id, scope, kb_id, thread_id, project_id, filename, sha256, status, error, "
|
||||
"num_chunks, stored_path, created_at "
|
||||
"FROM documents ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_document(conn: sqlite3.Connection, document_id: str) -> dict | None:
|
||||
row = conn.execute("SELECT * FROM documents WHERE id=?", (document_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ def get_resume_checkpoint_path(path_value: str) -> Optional[str]:
|
|||
def normalize_resume_output_dir(path_value: str) -> str:
|
||||
path = resolve_output_dir(path_value)
|
||||
if not _is_under_outputs(path):
|
||||
raise ValueError("Resume checkpoint must be inside Studio outputs.")
|
||||
raise ValueError("Resume checkpoint must be inside Unsloth outputs.")
|
||||
return str(path)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -797,7 +797,7 @@ class UnslothTrainer:
|
|||
)
|
||||
logger.info("Loaded text model")
|
||||
|
||||
raise_if_offloaded(self.model, device_map, "Studio training")
|
||||
raise_if_offloaded(self.model, device_map, "Unsloth training")
|
||||
|
||||
if self.should_stop:
|
||||
return False
|
||||
|
|
@ -3425,15 +3425,19 @@ class UnslothTrainer:
|
|||
logger.info(
|
||||
f"CPT: using UnslothTrainer with embedding_learning_rate={embedding_lr}\n"
|
||||
)
|
||||
cpt_args = _UnslothTrainingArguments(
|
||||
embedding_learning_rate = embedding_lr,
|
||||
**config_args,
|
||||
)
|
||||
if config_args.get("packing", False):
|
||||
cpt_args.packing_strategy = "wrapped"
|
||||
logger.info("CPT packing strategy: wrapped\n")
|
||||
trainer_kwargs = {
|
||||
"model": self.model,
|
||||
"tokenizer": sft_tokenizer,
|
||||
"train_dataset": dataset["dataset"],
|
||||
"data_collator": data_collator,
|
||||
"args": _UnslothTrainingArguments(
|
||||
embedding_learning_rate = embedding_lr,
|
||||
**config_args,
|
||||
),
|
||||
"args": cpt_args,
|
||||
}
|
||||
if eval_dataset is not None:
|
||||
trainer_kwargs["eval_dataset"] = eval_dataset
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ def should_use_mlx_training_backend(*, device: Optional[Any] = None) -> bool:
|
|||
|
||||
|
||||
def _build_training_worker_config(values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the normalized worker config shared by Studio and the CLI adapter."""
|
||||
"""Build the normalized worker config shared by Unsloth and the CLI adapter."""
|
||||
config = {
|
||||
"model_name": values["model_name"],
|
||||
"project_name": values.get("project_name"),
|
||||
|
|
@ -307,7 +307,7 @@ PLOT_HEIGHT = 3.5
|
|||
|
||||
@dataclass
|
||||
class TrainingProgress:
|
||||
"""Shared training progress payload for Studio and backend-aware trainers."""
|
||||
"""Shared training progress payload for Unsloth and backend-aware trainers."""
|
||||
|
||||
epoch: float = 0
|
||||
step: int = 0
|
||||
|
|
@ -328,7 +328,7 @@ class TrainingProgress:
|
|||
|
||||
|
||||
class _MLXTrainerAdapter:
|
||||
"""Adapts the legacy UnslothTrainer API to the shared Studio MLX worker path."""
|
||||
"""Adapts the legacy UnslothTrainer API to the shared Unsloth MLX worker path."""
|
||||
|
||||
def __init__(self):
|
||||
self.model = None
|
||||
|
|
|
|||
|
|
@ -1100,7 +1100,7 @@ _MLX_VLM_RESIZED_IMAGE_LAYOUT_CACHE = {}
|
|||
|
||||
|
||||
def _mlx_vlm_resized_image_layout(processor = None) -> str | None:
|
||||
"""Return the numpy image layout expected after Studio-side VLM resizing."""
|
||||
"""Return the numpy image layout expected after Unsloth-side VLM resizing."""
|
||||
image_processor = getattr(processor, "image_processor", None)
|
||||
if image_processor is None:
|
||||
return None
|
||||
|
|
@ -1257,7 +1257,7 @@ _MLX_STUDIO_LR_SCHEDULERS = {"linear", "cosine", "constant"}
|
|||
|
||||
|
||||
# Fallback alias map mirroring unsloth_zoo._normalize_mlx_optimizer_name, used
|
||||
# only when mlx (Apple Silicon) is not importable so Studio config validation
|
||||
# only when mlx (Apple Silicon) is not importable so Unsloth config validation
|
||||
# still works on non-MLX hosts. The zoo function stays the source of truth.
|
||||
_MLX_STUDIO_ADAMW_ALIASES = frozenset(
|
||||
(
|
||||
|
|
@ -1309,7 +1309,7 @@ def _normalize_mlx_studio_scheduler(value):
|
|||
|
||||
|
||||
def _resolve_mlx_local_dataset_files(file_paths: list) -> list[str]:
|
||||
"""Resolve CLI paths and Studio local dataset uploads without importing the GPU trainer."""
|
||||
"""Resolve CLI paths and Unsloth local dataset uploads without importing the GPU trainer."""
|
||||
from utils.paths import resolve_dataset_path
|
||||
|
||||
all_files: list[str] = []
|
||||
|
|
@ -1912,7 +1912,7 @@ def _run_mlx_training(event_queue, stop_queue, config):
|
|||
if "max_grad_leaf_norm" in _supported_fields:
|
||||
mlx_config_kwargs["max_grad_leaf_norm"] = max_grad_leaf_norm
|
||||
if "append_eos" in _supported_fields:
|
||||
# Studio SFT formatting owns rendered examples; raw/CPT text still
|
||||
# Unsloth SFT formatting owns rendered examples; raw/CPT text still
|
||||
# needs MLX to append EOS like the CUDA raw-text path.
|
||||
mlx_config_kwargs["append_eos"] = bool(raw_text_mode)
|
||||
|
||||
|
|
@ -2121,7 +2121,7 @@ def run_mlx_training_process(
|
|||
config: dict,
|
||||
transformers_activated: bool = False,
|
||||
) -> None:
|
||||
"""MLX worker entrypoint shared by Studio subprocesses and the CLI adapter."""
|
||||
"""MLX worker entrypoint shared by Unsloth subprocesses and the CLI adapter."""
|
||||
model_name = config["model_name"]
|
||||
|
||||
backend_path = str(Path(__file__).resolve().parent.parent.parent)
|
||||
|
|
@ -2780,7 +2780,7 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
)
|
||||
# Unified Windows APUs: the WDDM budget is user-raisable, but
|
||||
# nothing on the box says so -- users see "48 GB VRAM" on a
|
||||
# 96 GB machine and assume a Studio bug. Say where the limit
|
||||
# 96 GB machine and assume an Unsloth bug. Say where the limit
|
||||
# comes from and how to raise it.
|
||||
if _is_unified and sys.platform == "win32":
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@
|
|||
|
||||
from hub.routes.inventory import router as inventory_router
|
||||
from hub.routes.datasets import router as datasets_router
|
||||
from hub.routes.token import router as token_router
|
||||
|
||||
__all__ = [
|
||||
"inventory_router",
|
||||
"datasets_router",
|
||||
"token_router",
|
||||
]
|
||||
|
|
|
|||
44
studio/backend/hub/routes/token.py
Normal file
44
studio/backend/hub/routes/token.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Hugging Face token validation endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth.authentication import get_current_subject
|
||||
from hub.dependencies import get_hf_token
|
||||
from utils.client_ip import client_ip
|
||||
from utils.hf_token_validation import validate_hf_token
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class HfTokenValidationResponse(BaseModel):
|
||||
status: Literal["missing", "valid", "invalid", "rate_limited", "unavailable"]
|
||||
retry_after_seconds: Optional[int] = None
|
||||
|
||||
|
||||
@router.post("/token/validate", response_model = HfTokenValidationResponse)
|
||||
async def validate_token(
|
||||
request: Request,
|
||||
hf_token: Optional[str] = Depends(get_hf_token),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
if not hf_token:
|
||||
return HfTokenValidationResponse(status = "missing")
|
||||
result = await asyncio.to_thread(
|
||||
validate_hf_token,
|
||||
hf_token,
|
||||
rate_key = f"{current_subject}:{client_ip(request)}",
|
||||
)
|
||||
return HfTokenValidationResponse(
|
||||
status = result.status,
|
||||
retry_after_seconds = result.retry_after_seconds,
|
||||
)
|
||||
|
|
@ -8,6 +8,7 @@ import os
|
|||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
|
@ -75,7 +76,7 @@ def spawn_worker(
|
|||
env["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
|
||||
env["HF_HUB_DISABLE_TELEMETRY"] = "1"
|
||||
env["HF_HUB_DISABLE_XET"] = "0" if use_xet else "1"
|
||||
# No token in Studio settings: fall back to the backend's own HF_TOKEN so
|
||||
# No token in Unsloth settings: fall back to the backend's own HF_TOKEN so
|
||||
# private repos stay downloadable (needed while inkling repos are private).
|
||||
if not hf_token:
|
||||
hf_token = os.environ.get("HF_TOKEN") or None
|
||||
|
|
@ -210,7 +211,9 @@ def finalize_worker_exit(
|
|||
repo_type: Optional[RepoType] = None,
|
||||
repo_id: Optional[str] = None,
|
||||
transport: Optional[str] = None,
|
||||
) -> None:
|
||||
cancel_marker_transport: Optional[str] = None,
|
||||
defer_error: bool = False,
|
||||
) -> str:
|
||||
"""Block until *proc* exits, then record the job's terminal state in
|
||||
*registry*. Drains and scrubs stderr first, then classifies the exit code.
|
||||
A no-op when the process was already dropped (e.g. superseded).
|
||||
|
|
@ -222,7 +225,7 @@ def finalize_worker_exit(
|
|||
rc = proc.wait()
|
||||
cancel_requested = registry.cancel_requested(key)
|
||||
if not registry.drop_process(key, proc):
|
||||
return
|
||||
return "idle"
|
||||
stderr_text = download_registry.scrub_secrets(
|
||||
(stderr_data or b"").decode("utf-8", "replace").strip(),
|
||||
hf_token = hf_token,
|
||||
|
|
@ -230,6 +233,8 @@ def finalize_worker_exit(
|
|||
state = classify_exit(rc, cancel_requested = cancel_requested)
|
||||
if state == "complete":
|
||||
registry.set_job(key, "complete")
|
||||
if transport == download_registry.TRANSPORT_HTTP:
|
||||
registry.update_job_transport(key, download_registry.TRANSPORT_HTTP)
|
||||
if stderr_text:
|
||||
if download_manifest.MANIFEST_DEGRADED_MARKER in stderr_text:
|
||||
logger.warning(
|
||||
|
|
@ -262,18 +267,226 @@ def finalize_worker_exit(
|
|||
metadata.variant
|
||||
if metadata is not None and metadata.variant
|
||||
else download_registry.variant_from_key(key),
|
||||
transport,
|
||||
cancel_marker_transport or transport,
|
||||
logger = logger,
|
||||
)
|
||||
else:
|
||||
registry.set_job(
|
||||
key,
|
||||
"error",
|
||||
stderr_text or f"worker exited with code {rc}",
|
||||
)
|
||||
if not defer_error:
|
||||
registry.set_job(
|
||||
key,
|
||||
"error",
|
||||
stderr_text or f"worker exited with code {rc}",
|
||||
)
|
||||
logger.error(
|
||||
f"{log_prefix} failed for {label} (rc={rc}): {stderr_text}",
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
def _set_retry_failure_state(
|
||||
registry: download_registry.DownloadRegistry,
|
||||
key: str,
|
||||
error: str,
|
||||
*,
|
||||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
fallback_variant: Optional[str],
|
||||
fallback_transport: Optional[str],
|
||||
logger,
|
||||
) -> str:
|
||||
state, metadata = registry.set_error_unless_cancelled(key, error)
|
||||
if state == "cancelled":
|
||||
download_registry.persist_cancel_marker(
|
||||
repo_type,
|
||||
repo_id,
|
||||
metadata.variant if metadata is not None and metadata.variant else fallback_variant,
|
||||
metadata.transport
|
||||
if metadata is not None and metadata.transport
|
||||
else fallback_transport,
|
||||
logger = logger,
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
def _try_http_retry(
|
||||
registry: download_registry.DownloadRegistry,
|
||||
key: str,
|
||||
*,
|
||||
hf_token: Optional[str],
|
||||
label: str,
|
||||
log_prefix: str,
|
||||
logger,
|
||||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
watch_name: str,
|
||||
) -> bool:
|
||||
"""Reclaim *key* with HTTP transport and spawn a recovery worker.
|
||||
|
||||
Returns ``True`` when the HTTP worker was successfully registered.
|
||||
Caller is responsible for ensuring this is only called when: the job is
|
||||
in ``"error"`` state, the original transport was XET, and HTTP is available.
|
||||
|
||||
Derives variant and blob-hash metadata from the registry entry written by
|
||||
the original XET claim so callers do not re-construct worker arguments.
|
||||
Re-queries peer protection hashes at spawn time to reflect any concurrent
|
||||
sibling changes between the XET failure and this call.
|
||||
"""
|
||||
original_metadata = registry.get_job_metadata(key)
|
||||
if original_metadata is None:
|
||||
logger.debug("%s XET retry skipped for %s; metadata unavailable", log_prefix, label)
|
||||
_set_retry_failure_state(
|
||||
registry,
|
||||
key,
|
||||
"XET retry skipped: metadata unavailable",
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
fallback_variant = download_registry.variant_from_key(key),
|
||||
fallback_transport = download_registry.TRANSPORT_XET,
|
||||
logger = logger,
|
||||
)
|
||||
return False
|
||||
if original_metadata.transport != download_registry.TRANSPORT_XET:
|
||||
logger.debug(
|
||||
"%s XET retry skipped for %s; original transport was %s",
|
||||
log_prefix,
|
||||
label,
|
||||
original_metadata.transport,
|
||||
)
|
||||
_set_retry_failure_state(
|
||||
registry,
|
||||
key,
|
||||
f"XET retry skipped: original transport was {original_metadata.transport}",
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
fallback_variant = original_metadata.variant,
|
||||
fallback_transport = original_metadata.transport,
|
||||
logger = logger,
|
||||
)
|
||||
return False
|
||||
variant = original_metadata.variant
|
||||
blob_hashes = original_metadata.blob_hashes
|
||||
progress_blob_hashes = original_metadata.progress_blob_hashes
|
||||
completed_baseline_bytes = (
|
||||
download_registry.completed_blob_bytes(
|
||||
repo_type,
|
||||
repo_id,
|
||||
progress_blob_hashes,
|
||||
)
|
||||
if progress_blob_hashes
|
||||
else 0
|
||||
)
|
||||
generation = registry.current_generation(key)
|
||||
registry.release_active_slot(key)
|
||||
while True:
|
||||
if registry.cancel_requested(key):
|
||||
_set_retry_failure_state(
|
||||
registry,
|
||||
key,
|
||||
"HTTP retry cancelled before reclaiming the download slot",
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
fallback_variant = variant,
|
||||
fallback_transport = original_metadata.transport,
|
||||
logger = logger,
|
||||
)
|
||||
return False
|
||||
|
||||
claimed, conflict_state = registry.claim(
|
||||
key,
|
||||
download_registry.TRANSPORT_HTTP,
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
variant = variant,
|
||||
blob_hashes = blob_hashes,
|
||||
progress_blob_hashes = progress_blob_hashes,
|
||||
completed_baseline_bytes = completed_baseline_bytes,
|
||||
generation = generation,
|
||||
replace_active = True,
|
||||
cancel_marker_transport = original_metadata.transport,
|
||||
)
|
||||
if claimed:
|
||||
break
|
||||
if conflict_state == "deleting":
|
||||
logger.debug(
|
||||
"%s XET retry claim rejected for %s; repo is being deleted",
|
||||
log_prefix,
|
||||
label,
|
||||
)
|
||||
_set_retry_failure_state(
|
||||
registry,
|
||||
key,
|
||||
"HTTP retry could not reclaim the download slot",
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
fallback_variant = variant,
|
||||
fallback_transport = original_metadata.transport,
|
||||
logger = logger,
|
||||
)
|
||||
return False
|
||||
logger.debug(
|
||||
"%s XET retry claim blocked for %s by active sibling state %s; waiting",
|
||||
log_prefix,
|
||||
label,
|
||||
conflict_state,
|
||||
)
|
||||
time.sleep(0.05)
|
||||
|
||||
args: list[str] = ["--repo-id", repo_id]
|
||||
if repo_type == "dataset":
|
||||
args.append("--dataset")
|
||||
elif variant:
|
||||
args.extend(["--variant", variant])
|
||||
|
||||
# Re-query at spawn time: sibling state may have changed since XET failed.
|
||||
peer_hashes = registry.peer_blob_hashes(key) if variant else frozenset()
|
||||
|
||||
logger.warning(
|
||||
"%s XET worker failed for %s; retrying over HTTP",
|
||||
log_prefix,
|
||||
label,
|
||||
)
|
||||
try:
|
||||
proc = spawn_worker(
|
||||
args,
|
||||
hf_token,
|
||||
use_xet = False,
|
||||
protected_blob_hashes = peer_hashes or None,
|
||||
)
|
||||
except Exception as exc:
|
||||
scrubbed = download_registry.scrub_secrets(str(exc), hf_token = hf_token)
|
||||
logger.error(
|
||||
"%s HTTP retry spawn failed for %s: %s",
|
||||
log_prefix,
|
||||
label,
|
||||
scrubbed,
|
||||
)
|
||||
registry.update_job_transport(key, original_metadata.transport)
|
||||
_set_retry_failure_state(
|
||||
registry,
|
||||
key,
|
||||
scrubbed,
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
fallback_variant = variant,
|
||||
fallback_transport = original_metadata.transport,
|
||||
logger = logger,
|
||||
)
|
||||
return False
|
||||
|
||||
return register_worker(
|
||||
registry,
|
||||
key,
|
||||
proc,
|
||||
hf_token = hf_token,
|
||||
label = label,
|
||||
log_prefix = log_prefix,
|
||||
logger = logger,
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
transport = download_registry.TRANSPORT_HTTP,
|
||||
cancel_marker_transport = original_metadata.transport,
|
||||
watch_name = watch_name,
|
||||
)
|
||||
|
||||
|
||||
def kill_and_reap_process(
|
||||
|
|
@ -309,6 +522,7 @@ def register_worker(
|
|||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
transport: str,
|
||||
cancel_marker_transport: Optional[str] = None,
|
||||
watch_name: str,
|
||||
) -> bool:
|
||||
if not registry.register_process(key, proc):
|
||||
|
|
@ -319,7 +533,14 @@ def register_worker(
|
|||
|
||||
def _watch() -> None:
|
||||
try:
|
||||
finalize_worker_exit(
|
||||
can_retry_http = (
|
||||
transport == download_registry.TRANSPORT_XET
|
||||
and download_registry.download_transport_unavailable_reason(
|
||||
download_registry.TRANSPORT_HTTP
|
||||
)
|
||||
is None
|
||||
)
|
||||
state = finalize_worker_exit(
|
||||
registry,
|
||||
key,
|
||||
proc,
|
||||
|
|
@ -330,7 +551,25 @@ def register_worker(
|
|||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
transport = transport,
|
||||
cancel_marker_transport = cancel_marker_transport,
|
||||
defer_error = can_retry_http,
|
||||
)
|
||||
# XET-to-HTTP recovery: when a non-cancelled XET worker fails and
|
||||
# HTTP is available, attempt one automatic retry over HTTP. The
|
||||
# transport check is the recursion guard: an HTTP worker that errors
|
||||
# never satisfies `transport == TRANSPORT_XET`, so it stays terminal.
|
||||
if can_retry_http and state == "error":
|
||||
_try_http_retry(
|
||||
registry,
|
||||
key,
|
||||
hf_token = worker_token,
|
||||
label = label,
|
||||
log_prefix = log_prefix,
|
||||
logger = logger,
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
watch_name = watch_name,
|
||||
)
|
||||
except Exception:
|
||||
# finalize_worker_exit is the only thing that clears running/cancelling;
|
||||
# if it raises, force a terminal state so claim() isn't blocked until restart.
|
||||
|
|
@ -426,8 +665,19 @@ def cancel_worker(
|
|||
return "cancelling"
|
||||
return registry.get_job(key).state
|
||||
# Worker already exited; let its watcher classify the real return code.
|
||||
# Arming a pending cancel here could mislabel a genuine failure as a cancel.
|
||||
if proc.poll() is not None:
|
||||
get_metadata = getattr(registry, "get_job_metadata", None)
|
||||
metadata = get_metadata(key) if get_metadata is not None else None
|
||||
can_retry_http = (
|
||||
metadata is not None
|
||||
and metadata.transport == download_registry.TRANSPORT_XET
|
||||
and download_registry.download_transport_unavailable_reason(
|
||||
download_registry.TRANSPORT_HTTP
|
||||
)
|
||||
is None
|
||||
)
|
||||
if can_retry_http and registry.mark_pending_cancel(key, generation):
|
||||
return "cancelling"
|
||||
return registry.get_job(key).state
|
||||
|
||||
if not registry.request_cancel(key, proc, generation):
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ from hub.services.models.common import (
|
|||
_runtime_for_format,
|
||||
)
|
||||
|
||||
# Imported at module scope (not inside the per-repo scan loop) so a broken
|
||||
# import surfaces at startup instead of silently emptying the inventory: the
|
||||
# scan loop swallows per-repo exceptions and would drop every repo. Lives under
|
||||
# ``utils`` (not ``utils.models``) to avoid the eager model-config/checkpoint
|
||||
# imports in ``utils/models/__init__.py``.
|
||||
from utils.hidden_models import is_hidden_model
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_repo_size_cache: "OrderedDict[tuple[str, str, str], tuple[int, frozenset[str], float]]" = (
|
||||
|
|
@ -243,6 +250,13 @@ def invalidate_hf_cache_scans() -> None:
|
|||
hf_cache_scan.invalidate_hf_cache_scans()
|
||||
|
||||
|
||||
def _is_hidden_infra_repo(*values: str | None) -> bool:
|
||||
"""True for infra-only repos (the RAG embedder and the llama.cpp install
|
||||
validation probe) that are cached as a side effect of Studio itself and are
|
||||
not usable chat models."""
|
||||
return is_hidden_model(*values)
|
||||
|
||||
|
||||
def _scan_cached_gguf() -> list[dict]:
|
||||
"""Synchronous HF-cache disk walk for GGUF repos; runs in a worker thread."""
|
||||
cache_scans = all_hf_cache_scans()
|
||||
|
|
@ -254,13 +268,24 @@ def _scan_cached_gguf() -> list[dict]:
|
|||
if str(repo_info.repo_type) != "model":
|
||||
continue
|
||||
repo_id = repo_info.repo_id
|
||||
repo_path = Path(repo_info.repo_path)
|
||||
snapshot_path = _cached_model_snapshot_path(repo_path)
|
||||
total_size = _repo_gguf_size_bytes(repo_info)
|
||||
has_variant_state, variant_state_size = _gguf_variant_state_summary(repo_id)
|
||||
is_hidden_infra = _is_hidden_infra_repo(
|
||||
repo_id,
|
||||
str(repo_path),
|
||||
str(snapshot_path) if snapshot_path is not None else None,
|
||||
)
|
||||
# Hide infra repos unless the user downloaded a variant via
|
||||
# the Hub; variant state only exists for user downloads.
|
||||
if is_hidden_infra and not has_variant_state:
|
||||
continue
|
||||
if total_size == 0 and not has_variant_state:
|
||||
continue
|
||||
partial = hf_cache_scan.is_gguf_repo_partial(
|
||||
repo_id,
|
||||
Path(repo_info.repo_path),
|
||||
repo_path,
|
||||
)
|
||||
if total_size == 0 and not partial:
|
||||
continue
|
||||
|
|
@ -283,6 +308,9 @@ def _scan_cached_gguf() -> list[dict]:
|
|||
requires_variant = True,
|
||||
)
|
||||
)
|
||||
# Visible infra variants remain management-only.
|
||||
if is_hidden_infra:
|
||||
row["capabilities"]["can_chat"] = False
|
||||
if _prefer_cache_row(row, existing):
|
||||
seen_lower[key] = row
|
||||
except Exception as e:
|
||||
|
|
@ -475,6 +503,15 @@ def _scan_cached_models() -> list[dict]:
|
|||
if str(repo_info.repo_type) != "model":
|
||||
continue
|
||||
repo_id = repo_info.repo_id
|
||||
repo_path = Path(repo_info.repo_path)
|
||||
snapshot_path = _cached_model_snapshot_path(repo_path)
|
||||
# The non-GGUF embedder has no variant downloads; always hide.
|
||||
if _is_hidden_infra_repo(
|
||||
repo_id,
|
||||
str(repo_path),
|
||||
str(snapshot_path) if snapshot_path is not None else None,
|
||||
):
|
||||
continue
|
||||
has_main_gguf = _repo_has_gguf_files(repo_info)
|
||||
payload = _repo_non_gguf_model_payload(repo_info)
|
||||
if payload.size_bytes == 0:
|
||||
|
|
@ -486,7 +523,6 @@ def _scan_cached_models() -> list[dict]:
|
|||
continue
|
||||
key = repo_id.lower()
|
||||
existing = seen_lower.get(key)
|
||||
repo_path = Path(repo_info.repo_path)
|
||||
snapshot_partial = hf_cache_scan.is_snapshot_partial(
|
||||
"model",
|
||||
repo_id,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,30 @@ def _job_status(
|
|||
return DownloadJobStatus(state = state, error = error, generation = generation)
|
||||
|
||||
|
||||
def _load_in_flight(repo_id: str) -> bool:
|
||||
try:
|
||||
from core.inference.llama_cpp import hf_gguf_load_in_flight
|
||||
return hf_gguf_load_in_flight(repo_id)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _load_in_flight_error(repo_id: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code = 409,
|
||||
detail = (
|
||||
f"A model load for '{repo_id}' is in progress and may be "
|
||||
"downloading it. Wait for the load to finish (or cancel it), "
|
||||
"then start the download."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _reject_if_load_in_flight(repo_id: str) -> None:
|
||||
if _load_in_flight(repo_id):
|
||||
raise _load_in_flight_error(repo_id)
|
||||
|
||||
|
||||
def _spawn_download_worker(
|
||||
repo_id: str,
|
||||
variant: Optional[str],
|
||||
|
|
@ -89,6 +113,9 @@ async def download_model_response(body: DownloadModelRequest, hf_token: Optional
|
|||
# Canonicalize so two different-cased paste-ins share one job + cache dir.
|
||||
repo_id = await asyncio.to_thread(resolve_cached_repo_id_case, repo_id, repo_type = "model")
|
||||
|
||||
# Avoid concurrent writers to the same HF cache files.
|
||||
_reject_if_load_in_flight(repo_id)
|
||||
|
||||
variant = (body.gguf_variant or "").strip() or None
|
||||
if variant is not None and not _is_valid_gguf_variant(variant):
|
||||
raise HTTPException(
|
||||
|
|
@ -147,9 +174,12 @@ async def download_model_response(body: DownloadModelRequest, hf_token: Optional
|
|||
blob_hashes = variant_blob_hashes,
|
||||
progress_blob_hashes = variant_progress_blob_hashes,
|
||||
completed_baseline_bytes = completed_baseline_bytes,
|
||||
admission_check = lambda: not _load_in_flight(repo_id),
|
||||
)
|
||||
generation = _registry.current_generation(key)
|
||||
if not claimed:
|
||||
if claim_state == "admission_blocked":
|
||||
raise _load_in_flight_error(repo_id)
|
||||
# claim_state is the blocking job's state. The client can attach only
|
||||
# when the blocker is this key's own in-flight job (adoptable); a
|
||||
# cross-variant conflict or in-progress delete is not accepted.
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ def _looks_like_model_dir(directory: Path) -> bool:
|
|||
def _build_browse_allowlist(
|
||||
media_roots: Optional[list[Path]] = None, drive_roots: Optional[list[Path]] = None
|
||||
) -> list[Path]:
|
||||
"""Root directories the browser may walk (also seeds the suggestion chips): HOME, resolved HF cache dirs, Studio outputs/exports/root, registered scan folders, and well-known local-LLM dirs. Each is added only if it resolves to a real directory so the sandbox has no dead boundary.
|
||||
"""Root directories the browser may walk (also seeds the suggestion chips): HOME, resolved HF cache dirs, Unsloth outputs/exports/root, registered scan folders, and well-known local-LLM dirs. Each is added only if it resolves to a real directory so the sandbox has no dead boundary.
|
||||
|
||||
*media_roots* / *drive_roots* let the caller pass already-probed
|
||||
removable-media and Windows drive roots so they aren't scanned again (a
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ from hub.utils.paths import (
|
|||
)
|
||||
from hub.services.models import common as model_common
|
||||
from hub.services.models.ollama import scan_ollama_dir
|
||||
from utils.hidden_models import is_hidden_model
|
||||
|
||||
logger = get_logger(__name__)
|
||||
_MAX_MODELS_PER_CUSTOM_FOLDER = 200
|
||||
|
|
@ -623,6 +624,20 @@ def _dedupe_local_models(local_models: List[LocalModelInfo]) -> list[LocalModelI
|
|||
)
|
||||
|
||||
|
||||
def _filter_hidden_models(local_models: List[LocalModelInfo]) -> list[LocalModelInfo]:
|
||||
"""Remove infrastructure-only models from the shared local inventory."""
|
||||
visible: list[LocalModelInfo] = []
|
||||
for model in local_models:
|
||||
resolved_cache_path = (
|
||||
hf_cache_scan.resolve_hf_cache_realpath(Path(model.path))
|
||||
if model.source == "hf_cache"
|
||||
else None
|
||||
)
|
||||
if not is_hidden_model(model.id, model.model_id, model.path, resolved_cache_path):
|
||||
visible.append(model)
|
||||
return visible
|
||||
|
||||
|
||||
async def list_local_models_response(models_dir: str = "./models") -> LocalModelListResponse:
|
||||
"""List local model candidates from every supported on-device source."""
|
||||
hf_cache_dir = _resolve_hf_cache_dir()
|
||||
|
|
@ -653,7 +668,7 @@ async def list_local_models_response(models_dir: str = "./models") -> LocalModel
|
|||
ollama_dirs,
|
||||
)
|
||||
local_models += await _collect_models_from_custom_folders()
|
||||
models = _dedupe_local_models(local_models)
|
||||
models = _dedupe_local_models(_filter_hidden_models(local_models))
|
||||
|
||||
return LocalModelListResponse(
|
||||
models_dir = str(models_root),
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ def _contained_link_path(link_dir: Path, link_name: str) -> Optional[Path]:
|
|||
|
||||
|
||||
def _ollama_links_dir(ollama_dir: Path) -> Optional[Path]:
|
||||
"""Writable directory for Ollama ``.gguf`` symlinks. Prefers ``<ollama_dir>/.studio_links/`` next to the blobs; falls back to Studio's cache (read-only system installs), then the temp dir (sandboxed installs)."""
|
||||
"""Writable directory for Ollama ``.gguf`` symlinks. Prefers ``<ollama_dir>/.studio_links/`` next to the blobs; falls back to Unsloth's cache (read-only system installs), then the temp dir (sandboxed installs)."""
|
||||
|
||||
def _ensure_writable_dir(path: Path) -> Optional[Path]:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,27 +1,147 @@
|
|||
# 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 io
|
||||
import logging
|
||||
|
||||
from hub.services import download_lifecycle
|
||||
from hub.utils import download_registry, state_dir
|
||||
|
||||
|
||||
def _set_xet_reason(monkeypatch, reason):
|
||||
class _Proc:
|
||||
pid = 4242
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rc,
|
||||
stderr = b"",
|
||||
):
|
||||
self.rc = rc
|
||||
self.stderr = io.BytesIO(stderr)
|
||||
self.waited = False
|
||||
|
||||
def poll(self):
|
||||
return self.rc if self.waited else None
|
||||
|
||||
def wait(self, timeout = None):
|
||||
self.waited = True
|
||||
return self.rc
|
||||
|
||||
def kill(self):
|
||||
pass
|
||||
|
||||
|
||||
class _ImmediateThread:
|
||||
def __init__(self, *, target, **_kwargs):
|
||||
self.target = target
|
||||
|
||||
def start(self):
|
||||
self.target()
|
||||
|
||||
|
||||
def test_resolve_effective_use_xet(monkeypatch):
|
||||
for requested, unavailable_reason, expected in (
|
||||
(False, "unused", False),
|
||||
(True, None, True),
|
||||
(True, "hf_xet is not installed", False),
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
download_lifecycle.download_registry,
|
||||
"download_transport_unavailable_reason",
|
||||
lambda _transport, reason = unavailable_reason: reason,
|
||||
)
|
||||
assert download_lifecycle.resolve_effective_use_xet(requested) is expected
|
||||
|
||||
|
||||
def test_xet_failure_retries_over_http_for_model_and_dataset(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path / "state")
|
||||
monkeypatch.setattr(download_lifecycle.threading, "Thread", _ImmediateThread)
|
||||
register_worker = download_lifecycle.register_worker
|
||||
|
||||
for repo_type, repo_id, variant, expected_args in (
|
||||
("model", "Org/Model", "Q4_K_M", ["--repo-id", "Org/Model", "--variant", "Q4_K_M"]),
|
||||
("dataset", "Org/Data", None, ["--repo-id", "Org/Data", "--dataset"]),
|
||||
):
|
||||
registry = download_registry.DownloadRegistry()
|
||||
key = download_registry.normalize_job_key(f"{repo_id}::{variant}" if variant else repo_id)
|
||||
assert registry.claim(
|
||||
key,
|
||||
download_registry.TRANSPORT_XET,
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
variant = variant,
|
||||
blob_hashes = frozenset({"blob"}),
|
||||
)[0]
|
||||
generation = registry.current_generation(key)
|
||||
spawned = []
|
||||
|
||||
def fake_spawn(
|
||||
args,
|
||||
_token,
|
||||
*,
|
||||
use_xet,
|
||||
protected_blob_hashes = None,
|
||||
):
|
||||
spawned.append((args, use_xet, protected_blob_hashes))
|
||||
return _Proc(0)
|
||||
|
||||
def fake_retry_register(*_args, **kwargs):
|
||||
assert kwargs["transport"] == download_registry.TRANSPORT_HTTP
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(download_lifecycle, "spawn_worker", fake_spawn)
|
||||
monkeypatch.setattr(download_lifecycle, "register_worker", fake_retry_register)
|
||||
assert register_worker(
|
||||
registry,
|
||||
key,
|
||||
_Proc(1, b"xet failed"),
|
||||
hf_token = None,
|
||||
label = repo_id,
|
||||
log_prefix = "Download",
|
||||
logger = logging.getLogger("test"),
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
transport = download_registry.TRANSPORT_XET,
|
||||
watch_name = f"{repo_type}-watch",
|
||||
)
|
||||
|
||||
metadata = registry.get_job_metadata(key)
|
||||
assert spawned == [(expected_args, False, None)]
|
||||
assert metadata.transport == download_registry.TRANSPORT_HTTP
|
||||
assert metadata.blob_hashes == frozenset({"blob"})
|
||||
assert registry.current_generation(key) == generation
|
||||
|
||||
|
||||
def test_http_failure_remains_terminal(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path / "state")
|
||||
monkeypatch.setattr(download_lifecycle.threading, "Thread", _ImmediateThread)
|
||||
register_worker = download_lifecycle.register_worker
|
||||
registry = download_registry.DownloadRegistry()
|
||||
key = download_registry.normalize_repo_key("Org/Data")
|
||||
assert registry.claim(
|
||||
key,
|
||||
download_registry.TRANSPORT_HTTP,
|
||||
repo_type = "dataset",
|
||||
repo_id = "Org/Data",
|
||||
)[0]
|
||||
monkeypatch.setattr(
|
||||
download_lifecycle.download_registry,
|
||||
"download_transport_unavailable_reason",
|
||||
lambda _transport: reason,
|
||||
download_lifecycle,
|
||||
"register_worker",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("HTTP failures must not retry")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_effective_use_xet_keeps_http_when_not_requested(monkeypatch):
|
||||
_set_xet_reason(monkeypatch, "should not be consulted")
|
||||
assert download_lifecycle.resolve_effective_use_xet(False) is False
|
||||
|
||||
|
||||
def test_resolve_effective_use_xet_keeps_xet_when_available(monkeypatch):
|
||||
_set_xet_reason(monkeypatch, None)
|
||||
assert download_lifecycle.resolve_effective_use_xet(True) is True
|
||||
|
||||
|
||||
def test_resolve_effective_use_xet_downgrades_when_xet_unavailable(monkeypatch):
|
||||
_set_xet_reason(monkeypatch, "Xet transport is unavailable because hf_xet is not installed.")
|
||||
assert download_lifecycle.resolve_effective_use_xet(True) is False
|
||||
assert register_worker(
|
||||
registry,
|
||||
key,
|
||||
_Proc(1, b"http failed"),
|
||||
hf_token = None,
|
||||
label = "Org/Data",
|
||||
log_prefix = "Download",
|
||||
logger = logging.getLogger("test"),
|
||||
repo_type = "dataset",
|
||||
repo_id = "Org/Data",
|
||||
transport = download_registry.TRANSPORT_HTTP,
|
||||
watch_name = "dataset-watch",
|
||||
)
|
||||
assert registry.get_job(key).state == "error"
|
||||
|
|
|
|||
|
|
@ -439,6 +439,287 @@ def test_cached_gguf_scan_includes_variant_state_without_completed_gguf(monkeypa
|
|||
assert row["capabilities"]["requires_variant"] is True
|
||||
|
||||
|
||||
def test_cached_gguf_scan_hides_infra_repos_without_user_downloads(monkeypatch, tmp_path):
|
||||
probe = _repo(
|
||||
"ggml-org/models",
|
||||
[_file("tinyllamas/stories260K.gguf", 1_200_000)],
|
||||
tmp_path / "probe",
|
||||
)
|
||||
embedder = _repo(
|
||||
"unsloth/bge-small-en-v1.5-GGUF",
|
||||
[_file("bge-small-en-v1.5-f16.gguf", 60_000_000)],
|
||||
tmp_path / "embedder",
|
||||
)
|
||||
chat = _repo("Org/Chat-GGUF", [_file("Q4_K_M.gguf", 100)], tmp_path / "chat")
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [probe, embedder, chat])],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_gguf_repo_partial",
|
||||
lambda _repo_id, _path: False,
|
||||
)
|
||||
|
||||
result = {"cached": cache_inventory._scan_cached_gguf()}
|
||||
|
||||
assert [row["repo_id"] for row in result["cached"]] == ["Org/Chat-GGUF"]
|
||||
|
||||
|
||||
def test_cached_gguf_scan_keeps_infra_repo_with_user_downloaded_variant(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path / "state")
|
||||
embedder = _repo(
|
||||
"unsloth/bge-small-en-v1.5-GGUF",
|
||||
[
|
||||
_file("bge-small-en-v1.5-f16.gguf", 60_000_000),
|
||||
_file("bge-small-en-v1.5-Q8_0.gguf", 35_000_000),
|
||||
],
|
||||
tmp_path / "embedder",
|
||||
)
|
||||
# Variant manifests only exist for user Hub downloads, not auto-downloads.
|
||||
assert download_manifest.write_manifest(
|
||||
"model",
|
||||
"unsloth/bge-small-en-v1.5-GGUF",
|
||||
"Q8_0",
|
||||
[download_manifest.ExpectedFile(path = "bge-small-en-v1.5-Q8_0.gguf", size = 35_000_000)],
|
||||
"http",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [embedder])],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_gguf_repo_partial",
|
||||
lambda _repo_id, _path: False,
|
||||
)
|
||||
|
||||
result = {"cached": cache_inventory._scan_cached_gguf()}
|
||||
|
||||
assert [row["repo_id"] for row in result["cached"]] == ["unsloth/bge-small-en-v1.5-GGUF"]
|
||||
assert result["cached"][0]["capabilities"]["can_chat"] is False
|
||||
|
||||
|
||||
def test_cached_models_scan_hides_non_gguf_embedder(monkeypatch, tmp_path):
|
||||
embedder_path = tmp_path / "hub" / "models--unsloth--bge-small-en-v1.5"
|
||||
embedder_path.mkdir(parents = True)
|
||||
embedder = _repo(
|
||||
"unsloth/bge-small-en-v1.5",
|
||||
[_file("config.json", 12), _file("model.safetensors", 130_000_000)],
|
||||
embedder_path,
|
||||
)
|
||||
chat_path = tmp_path / "hub" / "models--Org--Chat"
|
||||
chat_path.mkdir(parents = True)
|
||||
chat = _repo(
|
||||
"Org/Chat",
|
||||
[_file("config.json", 12), _file("model.safetensors", 100)],
|
||||
chat_path,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [embedder, chat])],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_snapshot_partial",
|
||||
lambda _kind, _repo_id, _path: False,
|
||||
)
|
||||
|
||||
result = {"cached": cache_inventory._scan_cached_models()}
|
||||
|
||||
assert [row["repo_id"] for row in result["cached"]] == ["Org/Chat"]
|
||||
|
||||
|
||||
def test_cached_scans_hide_embedders_configured_by_cache_path(monkeypatch, tmp_path):
|
||||
from core.rag import config as rag_config
|
||||
|
||||
gguf_path = tmp_path / "hub" / "models--Org--PathEmbedder-GGUF"
|
||||
gguf_path.mkdir(parents = True)
|
||||
gguf = _repo(
|
||||
"Org/PathEmbedder-GGUF",
|
||||
[_file("model-F16.gguf", 60_000_000)],
|
||||
gguf_path,
|
||||
)
|
||||
model_path = tmp_path / "hub" / "models--Org--PathEmbedder"
|
||||
model_path.mkdir(parents = True)
|
||||
model = _repo(
|
||||
"Org/PathEmbedder",
|
||||
[_file("config.json", 12), _file("model.safetensors", 130_000_000)],
|
||||
model_path,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rag_config,
|
||||
"effective_embedding_model",
|
||||
lambda: str(model_path),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rag_config,
|
||||
"effective_gguf_repo",
|
||||
lambda: str(gguf_path),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [gguf, model])],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_gguf_repo_partial",
|
||||
lambda _repo_id, _path: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_snapshot_partial",
|
||||
lambda _kind, _repo_id, _path: False,
|
||||
)
|
||||
|
||||
assert cache_inventory._scan_cached_gguf() == []
|
||||
assert cache_inventory._scan_cached_models() == []
|
||||
|
||||
|
||||
def test_cached_scans_hide_embedders_configured_by_snapshot_path(monkeypatch, tmp_path):
|
||||
from core.rag import config as rag_config
|
||||
|
||||
gguf_path = tmp_path / "hub" / "models--Org--SnapshotEmbedder-GGUF"
|
||||
gguf_snapshot = gguf_path / "snapshots" / "gguf-revision"
|
||||
gguf_snapshot.mkdir(parents = True)
|
||||
gguf = _repo(
|
||||
"Org/SnapshotEmbedder-GGUF",
|
||||
[_file("model-F16.gguf", 60_000_000)],
|
||||
gguf_path,
|
||||
)
|
||||
model_path = tmp_path / "hub" / "models--Org--SnapshotEmbedder"
|
||||
model_snapshot = model_path / "snapshots" / "model-revision"
|
||||
model_snapshot.mkdir(parents = True)
|
||||
model = _repo(
|
||||
"Org/SnapshotEmbedder",
|
||||
[_file("config.json", 12), _file("model.safetensors", 130_000_000)],
|
||||
model_path,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rag_config,
|
||||
"effective_embedding_model",
|
||||
lambda: str(model_snapshot),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rag_config,
|
||||
"effective_gguf_repo",
|
||||
lambda: str(gguf_snapshot),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [gguf, model])],
|
||||
)
|
||||
|
||||
def _resolve_snapshot(repo_path):
|
||||
return str(
|
||||
{
|
||||
gguf_path: gguf_snapshot,
|
||||
model_path: model_snapshot,
|
||||
}.get(Path(repo_path), Path(repo_path))
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"resolve_hf_cache_realpath",
|
||||
_resolve_snapshot,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_gguf_repo_partial",
|
||||
lambda _repo_id, _path: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_snapshot_partial",
|
||||
lambda _kind, _repo_id, _path: False,
|
||||
)
|
||||
|
||||
assert cache_inventory._scan_cached_gguf() == []
|
||||
assert cache_inventory._scan_cached_models() == []
|
||||
|
||||
|
||||
def test_cached_models_scan_keeps_unrelated_repo_with_custom_generic_embedder(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
# A custom embedder with a generic basename ("org/model") must be hidden by
|
||||
# EXACT repo-id match only. An unrelated cached chat model whose id merely
|
||||
# contains "model" (e.g. "user/model-chat") must stay on device: substring
|
||||
# basename matching used to drop real chat models from the inventory.
|
||||
from core.rag import config as rag_config
|
||||
|
||||
monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "org/model")
|
||||
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/model-GGUF")
|
||||
|
||||
def _model_repo(repo_id: str):
|
||||
path = tmp_path / "hub" / f"models--{repo_id.replace('/', '--')}"
|
||||
path.mkdir(parents = True)
|
||||
return _repo(
|
||||
repo_id,
|
||||
[_file("config.json", 12), _file("model.safetensors", 100)],
|
||||
path,
|
||||
)
|
||||
|
||||
embedder = _model_repo("org/model")
|
||||
chat = _model_repo("user/model-chat")
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [embedder, chat])],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_snapshot_partial",
|
||||
lambda _kind, _repo_id, _path: False,
|
||||
)
|
||||
|
||||
result = {"cached": cache_inventory._scan_cached_models()}
|
||||
|
||||
assert [row["repo_id"] for row in result["cached"]] == ["user/model-chat"]
|
||||
|
||||
|
||||
def test_cached_scans_hide_stale_default_embedder_after_custom_setting(monkeypatch, tmp_path):
|
||||
from core.rag import config as rag_config
|
||||
|
||||
monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "org/custom")
|
||||
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/custom-GGUF")
|
||||
|
||||
gguf = _repo(
|
||||
"unsloth/bge-small-en-v1.5-GGUF",
|
||||
[_file("bge-small-en-v1.5-f16.gguf", 60_000_000)],
|
||||
tmp_path / "default-gguf",
|
||||
)
|
||||
weights_path = tmp_path / "hub" / "models--unsloth--bge-small-en-v1.5"
|
||||
weights_path.mkdir(parents = True)
|
||||
weights = _repo(
|
||||
"unsloth/bge-small-en-v1.5",
|
||||
[_file("config.json", 12), _file("model.safetensors", 130_000_000)],
|
||||
weights_path,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [gguf, weights])],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_gguf_repo_partial",
|
||||
lambda _repo_id, _path: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_snapshot_partial",
|
||||
lambda _kind, _repo_id, _path: False,
|
||||
)
|
||||
|
||||
assert cache_inventory._scan_cached_gguf() == []
|
||||
assert cache_inventory._scan_cached_models() == []
|
||||
|
||||
|
||||
def test_gguf_variant_requirements_include_split_files_and_preferred_mmproj():
|
||||
requirements = gguf_variants._build_gguf_variant_requirements(
|
||||
[
|
||||
|
|
@ -1610,6 +1891,63 @@ def test_hf_cache_scan_uses_gguf_partial_row_for_variant_state(monkeypatch, tmp_
|
|||
assert rows[0].capabilities.requires_variant is True
|
||||
|
||||
|
||||
def test_local_inventory_filters_custom_embedder_hf_cache_row(monkeypatch, tmp_path):
|
||||
from core.rag import config as rag_config
|
||||
|
||||
monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "org/embedder")
|
||||
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/embedder-GGUF")
|
||||
|
||||
def _row(repo_id: str):
|
||||
repo_path = tmp_path / f"models--{repo_id.replace('/', '--')}"
|
||||
return model_common._local_model_info(
|
||||
scan_path = repo_path,
|
||||
load_path = repo_path,
|
||||
source = "hf_cache",
|
||||
model_format = "safetensors",
|
||||
model_id = repo_id,
|
||||
)
|
||||
|
||||
rows = local_inventory._filter_hidden_models([_row("org/embedder"), _row("org/chat-model")])
|
||||
|
||||
assert [row.model_id for row in rows] == ["org/chat-model"]
|
||||
|
||||
|
||||
def test_local_inventory_filters_embedder_configured_by_snapshot_path(monkeypatch, tmp_path):
|
||||
from core.rag import config as rag_config
|
||||
|
||||
embedder_path = tmp_path / "hub" / "models--org--embedder"
|
||||
embedder_snapshot = embedder_path / "snapshots" / "revision"
|
||||
embedder_snapshot.mkdir(parents = True)
|
||||
chat_path = tmp_path / "hub" / "models--org--chat-model"
|
||||
chat_path.mkdir(parents = True)
|
||||
monkeypatch.setattr(
|
||||
rag_config,
|
||||
"effective_embedding_model",
|
||||
lambda: str(embedder_snapshot),
|
||||
)
|
||||
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/embedder-GGUF")
|
||||
monkeypatch.setattr(
|
||||
local_inventory.hf_cache_scan,
|
||||
"resolve_hf_cache_realpath",
|
||||
lambda path: str(embedder_snapshot) if Path(path) == embedder_path else str(path),
|
||||
)
|
||||
|
||||
def _row(repo_id: str, repo_path: Path):
|
||||
return model_common._local_model_info(
|
||||
scan_path = repo_path,
|
||||
load_path = repo_path,
|
||||
source = "hf_cache",
|
||||
model_format = "safetensors",
|
||||
model_id = repo_id,
|
||||
)
|
||||
|
||||
rows = local_inventory._filter_hidden_models(
|
||||
[_row("org/embedder", embedder_path), _row("org/chat-model", chat_path)]
|
||||
)
|
||||
|
||||
assert [row.model_id for row in rows] == ["org/chat-model"]
|
||||
|
||||
|
||||
def test_model_download_job_helpers_preserve_idle_shape():
|
||||
key = downloads._download_job_key("Org/Model", None)
|
||||
status = downloads._job_status(key)
|
||||
|
|
|
|||
|
|
@ -45,9 +45,9 @@ import sys
|
|||
import threading
|
||||
import time
|
||||
import weakref
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Literal, Optional
|
||||
from typing import Callable, Iterator, Literal, Optional
|
||||
|
||||
from loggers import get_logger
|
||||
|
||||
|
|
@ -126,6 +126,9 @@ def write_worker_breadcrumb(key: str, pid: int, metadata: Optional["DownloadMeta
|
|||
"repo_id": metadata.repo_id if metadata is not None else None,
|
||||
"variant": metadata.variant if metadata is not None else None,
|
||||
"transport": metadata.transport if metadata is not None else None,
|
||||
"cancel_marker_transport": metadata.cancel_marker_transport
|
||||
if metadata is not None
|
||||
else None,
|
||||
}
|
||||
tmp = path.with_name(f".{path.name}.tmp-{pid}")
|
||||
try:
|
||||
|
|
@ -305,7 +308,7 @@ def reap_orphan_workers() -> None:
|
|||
data.get("repo_type"),
|
||||
repo_id,
|
||||
data.get("variant"),
|
||||
data.get("transport"),
|
||||
data.get("cancel_marker_transport") or data.get("transport"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Reaper failed for breadcrumb %s: %s", entry, exc)
|
||||
|
|
@ -699,6 +702,7 @@ class DownloadMetadata:
|
|||
repo_id: str
|
||||
variant: Optional[str]
|
||||
transport: Optional[str]
|
||||
cancel_marker_transport: Optional[str] = None
|
||||
# GGUF variant main/writable hashes, identifying the variant-specific shards
|
||||
# for concurrency decisions.
|
||||
blob_hashes: frozenset[str] = field(default_factory = frozenset)
|
||||
|
|
@ -801,6 +805,7 @@ class DownloadRegistry:
|
|||
self._processes: dict[str, subprocess.Popen] = {}
|
||||
self._repo_active: dict[str, set[str]] = {}
|
||||
self._metadata: dict[str, DownloadMetadata] = {}
|
||||
self._cancel_marker_transports: dict[str, str] = {}
|
||||
self._pending_cancel: dict[str, Optional[int]] = {}
|
||||
self._generations: dict[str, int] = {}
|
||||
# Monotonic across keys so an evicted then re-claimed key never reuses a
|
||||
|
|
@ -839,6 +844,7 @@ class DownloadRegistry:
|
|||
if state in TERMINAL_STATES:
|
||||
self._put_terminal_job_locked(key, state, error)
|
||||
self._pending_cancel.pop(key, None)
|
||||
self._cancel_marker_transports.pop(key, None)
|
||||
repo = _repo_of_key(key)
|
||||
active = self._repo_active.get(repo)
|
||||
if active is not None:
|
||||
|
|
@ -848,6 +854,57 @@ class DownloadRegistry:
|
|||
else:
|
||||
self._jobs[key] = DownloadState(state, error)
|
||||
|
||||
def set_error_unless_cancelled(
|
||||
self, key: str, error: str
|
||||
) -> tuple[JobState, Optional[DownloadMetadata]]:
|
||||
key = normalize_job_key(key)
|
||||
with self._lock:
|
||||
current = self._jobs.get(key, DownloadState("idle")).state
|
||||
has_pending_cancel = key in self._pending_cancel
|
||||
pending_generation = self._pending_cancel.get(key)
|
||||
metadata = self._metadata.get(key)
|
||||
should_cancel = current == "cancelling" or (
|
||||
has_pending_cancel and self._generation_matches_locked(key, pending_generation)
|
||||
)
|
||||
terminal_state: JobState = "cancelled" if should_cancel else "error"
|
||||
marker_transport = self._cancel_marker_transports.pop(key, None)
|
||||
if marker_transport is None and metadata is not None:
|
||||
marker_transport = metadata.cancel_marker_transport
|
||||
self._put_terminal_job_locked(
|
||||
key,
|
||||
terminal_state,
|
||||
None if should_cancel else error,
|
||||
)
|
||||
self._pending_cancel.pop(key, None)
|
||||
repo = _repo_of_key(key)
|
||||
active = self._repo_active.get(repo)
|
||||
if active is not None:
|
||||
active.discard(key)
|
||||
if not active:
|
||||
self._repo_active.pop(repo, None)
|
||||
if should_cancel and metadata is not None and marker_transport is not None:
|
||||
metadata = replace(metadata, transport = marker_transport)
|
||||
return terminal_state, metadata
|
||||
|
||||
def update_job_transport(self, key: str, transport: str) -> None:
|
||||
key = normalize_job_key(key)
|
||||
with self._lock:
|
||||
metadata = self._metadata.get(key)
|
||||
if metadata is None or metadata.transport == transport:
|
||||
return
|
||||
self._metadata[key] = replace(metadata, transport = transport)
|
||||
|
||||
def release_active_slot(self, key: str) -> None:
|
||||
key = normalize_job_key(key)
|
||||
repo = _repo_of_key(key)
|
||||
with self._lock:
|
||||
active = self._repo_active.get(repo)
|
||||
if active is None:
|
||||
return
|
||||
active.discard(key)
|
||||
if not active:
|
||||
self._repo_active.pop(repo, None)
|
||||
|
||||
def get_job(self, key: str) -> DownloadState:
|
||||
key = normalize_job_key(key)
|
||||
with self._lock:
|
||||
|
|
@ -884,6 +941,14 @@ class DownloadRegistry:
|
|||
):
|
||||
self._put_terminal_job_locked(key, "cancelled")
|
||||
metadata_to_persist = self._metadata.pop(key, None)
|
||||
marker_transport = self._cancel_marker_transports.pop(key, None)
|
||||
if marker_transport is None and metadata_to_persist is not None:
|
||||
marker_transport = metadata_to_persist.cancel_marker_transport
|
||||
if metadata_to_persist is not None and marker_transport is not None:
|
||||
metadata_to_persist = replace(
|
||||
metadata_to_persist,
|
||||
transport = marker_transport,
|
||||
)
|
||||
repo = _repo_of_key(key)
|
||||
active = self._repo_active.get(repo)
|
||||
if active is not None:
|
||||
|
|
@ -963,12 +1028,24 @@ class DownloadRegistry:
|
|||
blob_hashes: Optional[frozenset[str]] = None,
|
||||
progress_blob_hashes: Optional[frozenset[str]] = None,
|
||||
completed_baseline_bytes: int = 0,
|
||||
admission_check: Optional[Callable[[], bool]] = None,
|
||||
generation: Optional[int] = None,
|
||||
replace_active: bool = False,
|
||||
metadata_transport: Optional[str] = None,
|
||||
cancel_marker_transport: Optional[str] = None,
|
||||
) -> tuple[bool, str]:
|
||||
key = normalize_job_key(key)
|
||||
repo = _repo_of_key(key)
|
||||
requested_hashes = blob_hashes or frozenset()
|
||||
requested_progress_hashes = progress_blob_hashes or frozenset()
|
||||
with self._lock:
|
||||
# Run the final external admission check while the registry lock is
|
||||
# held, immediately before inspecting and publishing active state.
|
||||
# The GGUF load path establishes its marker before calling
|
||||
# its active-job probe, so either this claim observes that marker
|
||||
# or the load's later probe observes this claim.
|
||||
if admission_check is not None and not admission_check():
|
||||
return False, "admission_blocked"
|
||||
deleting_scopes = self._deleting.get(repo)
|
||||
if deleting_scopes is not None and (
|
||||
None in deleting_scopes or variant_from_key(key) in deleting_scopes
|
||||
|
|
@ -1007,10 +1084,13 @@ class DownloadRegistry:
|
|||
if conflict_state is not None:
|
||||
return False, conflict_state
|
||||
current = self._jobs.get(key, DownloadState("idle")).state
|
||||
if current in _ACTIVE_STATES:
|
||||
if current in _ACTIVE_STATES and not replace_active:
|
||||
return False, current
|
||||
self._generation_seq += 1
|
||||
self._generations[key] = self._generation_seq
|
||||
if generation is None:
|
||||
self._generation_seq += 1
|
||||
self._generations[key] = self._generation_seq
|
||||
else:
|
||||
self._generations[key] = generation
|
||||
self._jobs[key] = DownloadState("running")
|
||||
self._repo_active.setdefault(repo, active).add(key)
|
||||
if repo_type and repo_id:
|
||||
|
|
@ -1018,7 +1098,8 @@ class DownloadRegistry:
|
|||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
variant = variant,
|
||||
transport = transport,
|
||||
transport = metadata_transport if metadata_transport is not None else transport,
|
||||
cancel_marker_transport = cancel_marker_transport,
|
||||
blob_hashes = requested_hashes,
|
||||
progress_blob_hashes = requested_progress_hashes,
|
||||
completed_baseline_bytes = max(
|
||||
|
|
@ -1026,8 +1107,13 @@ class DownloadRegistry:
|
|||
int(completed_baseline_bytes or 0),
|
||||
),
|
||||
)
|
||||
if cancel_marker_transport is not None:
|
||||
self._cancel_marker_transports[key] = cancel_marker_transport
|
||||
else:
|
||||
self._cancel_marker_transports.pop(key, None)
|
||||
else:
|
||||
self._metadata.pop(key, None)
|
||||
self._cancel_marker_transports.pop(key, None)
|
||||
return True, "running"
|
||||
|
||||
def adoptable(self, key: str) -> bool:
|
||||
|
|
@ -1053,7 +1139,8 @@ class DownloadRegistry:
|
|||
download. A variant delete conflicts only with that same variant or a
|
||||
whole-repo download writing the shared snapshot; other quantizations
|
||||
download concurrently and never block it."""
|
||||
for key in self._repo_active.get(repo_id, set()):
|
||||
active_keys = self._repo_active.get(repo_id, set())
|
||||
for key in active_keys:
|
||||
job = self._jobs.get(key)
|
||||
if job is None or job.state not in _ACTIVE_STATES:
|
||||
continue
|
||||
|
|
@ -1062,6 +1149,16 @@ class DownloadRegistry:
|
|||
other_variant = self._active_job_variant_locked(key)
|
||||
if other_variant is None or other_variant == variant:
|
||||
return True
|
||||
for key, job in self._jobs.items():
|
||||
if key in active_keys or _repo_of_key(key) != repo_id:
|
||||
continue
|
||||
if job.state not in _ACTIVE_STATES:
|
||||
continue
|
||||
if variant is None:
|
||||
return True
|
||||
other_variant = self._active_job_variant_locked(key)
|
||||
if other_variant is None or other_variant == variant:
|
||||
return True
|
||||
return False
|
||||
|
||||
def peer_blob_hashes(self, key: str) -> frozenset[str]:
|
||||
|
|
@ -1108,6 +1205,16 @@ class DownloadRegistry:
|
|||
candidate_keys = list(self._repo_active.get(repo_key, set()))
|
||||
else:
|
||||
candidate_keys = [key for active in self._repo_active.values() for key in active]
|
||||
# An XET->HTTP retry handoff briefly drops its key from _repo_active
|
||||
# while its job stays active; include those released-but-active jobs
|
||||
# so the waiting retry still lists and can be adopted or cancelled.
|
||||
seen = set(candidate_keys)
|
||||
for key, job in self._jobs.items():
|
||||
if key in seen or job.state not in _ACTIVE_STATES:
|
||||
continue
|
||||
if repo_key is not None and _repo_of_key(key) != repo_key:
|
||||
continue
|
||||
candidate_keys.append(key)
|
||||
refs: list[ActiveDownloadRef] = []
|
||||
for key in candidate_keys:
|
||||
job = self._jobs.get(key)
|
||||
|
|
@ -1123,6 +1230,23 @@ class DownloadRegistry:
|
|||
)
|
||||
return refs
|
||||
|
||||
def has_active_variant(self, repo_id: str, variant: Optional[str]) -> bool:
|
||||
"""Whether an active model job targets this exact GGUF variant.
|
||||
|
||||
Scans the job table rather than only ``_repo_active`` so an XET-to-HTTP
|
||||
retry handoff remains visible while it has temporarily released its
|
||||
active slot.
|
||||
"""
|
||||
repo_key = normalize_repo_key(repo_id)
|
||||
target = (variant or "").strip().lower() or None
|
||||
with self._lock:
|
||||
for key, job in self._jobs.items():
|
||||
if _repo_of_key(key) != repo_key or job.state not in _ACTIVE_STATES:
|
||||
continue
|
||||
if self._active_job_variant_locked(key) == target:
|
||||
return True
|
||||
return False
|
||||
|
||||
def begin_delete(
|
||||
self,
|
||||
repo_id: str,
|
||||
|
|
@ -1169,12 +1293,25 @@ class DownloadRegistry:
|
|||
repo_id = normalize_repo_key(repo_id)
|
||||
target = (variant or "").strip().lower() or None
|
||||
with self._lock:
|
||||
for key in self._repo_active.get(repo_id, set()):
|
||||
active_keys = self._repo_active.get(repo_id, set())
|
||||
for key in active_keys:
|
||||
job = self._jobs.get(key)
|
||||
if job is None or job.state not in _ACTIVE_STATES:
|
||||
continue
|
||||
if self._active_job_variant_locked(key) != target:
|
||||
return True
|
||||
# An XET->HTTP retry peer between release_active_slot() and its reclaim
|
||||
# is briefly absent from _repo_active while its job stays active and
|
||||
# still owns the shared companion; mirror the released-but-active scan
|
||||
# used by _delete_blocked_by_active_locked so it still blocks companion
|
||||
# deletion of a different variant.
|
||||
for key, job in self._jobs.items():
|
||||
if key in active_keys or _repo_of_key(key) != repo_id:
|
||||
continue
|
||||
if job.state not in _ACTIVE_STATES:
|
||||
continue
|
||||
if self._active_job_variant_locked(key) != target:
|
||||
return True
|
||||
return False
|
||||
|
||||
def request_cancel(
|
||||
|
|
@ -1198,17 +1335,58 @@ class DownloadRegistry:
|
|||
return True
|
||||
|
||||
def terminate_all(self, kind: str = "download") -> None:
|
||||
settled_no_proc: list[Optional[DownloadMetadata]] = []
|
||||
with self._lock:
|
||||
live = [
|
||||
(key, proc, self._metadata.get(key))
|
||||
for key, proc in self._processes.items()
|
||||
if proc.poll() is None
|
||||
]
|
||||
live_keys = {key for key, _proc, _metadata in live}
|
||||
# Flag as an intentional stop so the watcher's exit classification
|
||||
# reports them cancelled rather than an OOM/crash once SIGKILL lands.
|
||||
for key, _proc, _metadata in live:
|
||||
if self._jobs.get(key, DownloadState("idle")).state == "running":
|
||||
self._jobs[key] = DownloadState("cancelling")
|
||||
# Settle active jobs without a live worker too. Two cases: an
|
||||
# XET->HTTP retry parked in the reclaim wait loop has dropped its
|
||||
# worker and slot guard, so it is absent from `live`; and a
|
||||
# registered worker that already exited with an error but whose
|
||||
# watcher has not yet run would otherwise stay `running` and spawn an
|
||||
# HTTP retry after this shutdown snapshot. Skip a registered worker
|
||||
# that exited cleanly (rc == 0): it completed and the watcher will
|
||||
# mark it done, so marking it cancelling would strand a stale marker.
|
||||
for key, job in list(self._jobs.items()):
|
||||
if job.state not in _ACTIVE_STATES or key in live_keys:
|
||||
continue
|
||||
proc = self._processes.get(key)
|
||||
if proc is not None:
|
||||
if proc.poll() == 0:
|
||||
continue
|
||||
# A registered worker that exited nonzero on its own over HTTP
|
||||
# is a genuine terminal download failure, not a shutdown cancel
|
||||
# and not retry-capable: leave its error status intact rather
|
||||
# than persisting a cancel marker that would read as
|
||||
# cancelled/resumable after restart. Only an exited XET worker
|
||||
# could still spawn a post-shutdown HTTP retry, so only that
|
||||
# needs settling here.
|
||||
metadata = self._metadata.get(key)
|
||||
if metadata is not None and metadata.transport == TRANSPORT_HTTP:
|
||||
continue
|
||||
self._pending_cancel[key] = self._generations.get(key)
|
||||
self._jobs[key] = DownloadState("cancelling")
|
||||
settled_no_proc.append(self._metadata.get(key))
|
||||
# Persist a cancel marker for each settled no-live-worker job outside the
|
||||
# lock (mirroring the reaped path) so shutdown records resumable/cancelled
|
||||
# state even if it returns before the daemon watcher wakes to do so.
|
||||
for metadata in settled_no_proc:
|
||||
if metadata is not None:
|
||||
persist_cancel_marker(
|
||||
metadata.repo_type,
|
||||
metadata.repo_id,
|
||||
metadata.variant,
|
||||
metadata.cancel_marker_transport or metadata.transport,
|
||||
)
|
||||
reaped: list[tuple[str, subprocess.Popen, Optional[DownloadMetadata]]] = []
|
||||
for key, proc, metadata in live:
|
||||
try:
|
||||
|
|
@ -1222,7 +1400,7 @@ class DownloadRegistry:
|
|||
metadata.repo_type,
|
||||
metadata.repo_id,
|
||||
metadata.variant,
|
||||
metadata.transport,
|
||||
metadata.cancel_marker_transport or metadata.transport,
|
||||
)
|
||||
continue
|
||||
reaped.append((key, proc, metadata))
|
||||
|
|
@ -1242,7 +1420,7 @@ class DownloadRegistry:
|
|||
metadata.repo_type,
|
||||
metadata.repo_id,
|
||||
metadata.variant,
|
||||
metadata.transport,
|
||||
metadata.cancel_marker_transport or metadata.transport,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
"""Filesystem layout for Hub download state.
|
||||
|
||||
State directory sits beside HF's cache (under Studio's own cache root)
|
||||
State directory sits beside HF's cache (under Unsloth's own cache root)
|
||||
so it survives ``huggingface-cli delete-cache`` and any other HF-side
|
||||
cache lifecycle. Two subdirectories:
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ os.environ["PYTHONWARNINGS"] = "ignore"
|
|||
|
||||
# Pin GPU index ordering to PCI bus id before any torch import creates a CUDA
|
||||
# context. Without this, torch/CUDA default to FASTEST_FIRST while nvidia-smi
|
||||
# (and Studio's VRAM probes) use PCI-bus order, so a GPU index chosen from
|
||||
# (and Unsloth's VRAM probes) use PCI-bus order, so a GPU index chosen from
|
||||
# nvidia-smi data can resolve to a different physical card via
|
||||
# CUDA_VISIBLE_DEVICES. setdefault so an explicit user override wins. See
|
||||
# utils/hardware/hardware.py for the full rationale; set here too so the entry
|
||||
|
|
@ -93,7 +93,7 @@ if sys.platform == "win32":
|
|||
# ── Windows AMD ROCm: make hipInfo.exe resolvable for subprocess probes ──
|
||||
# bitsandbytes' get_rocm_gpu_arch() runs `hipinfo.exe` via PATH at import
|
||||
# time; the AMD torch wheel ships it in the venv Scripts dir, which is on
|
||||
# PATH only when the venv is activated -- Studio launches python directly.
|
||||
# PATH only when the venv is activated -- Unsloth launches python directly.
|
||||
# Without this, every bitsandbytes import logs a scary (but harmless)
|
||||
# "Could not detect ROCm GPU architecture: [WinError 2]" ERROR + WARNING.
|
||||
# Gated on the file existing: only AMD ROCm wheels ship hipInfo.exe, so
|
||||
|
|
@ -252,7 +252,7 @@ def _read_studio_install_id() -> str:
|
|||
|
||||
Returns "" when absent or not a 64-char lowercase-hex token; then
|
||||
/api/health emits "" and the launcher accepts any healthy backend.
|
||||
Carries no install-path info (matters when Studio runs -H 0.0.0.0)."""
|
||||
Carries no install-path info (matters when Unsloth runs -H 0.0.0.0)."""
|
||||
try:
|
||||
token = (_STUDIO_ROOT_RESOLVED / "share" / "studio_install_id").read_text().strip()
|
||||
except (OSError, ValueError):
|
||||
|
|
@ -312,6 +312,7 @@ from routes.preview import router as preview_router
|
|||
from hub.routes import (
|
||||
inventory_router as hub_inventory_router,
|
||||
datasets_router as hub_datasets_router,
|
||||
token_router as hub_token_router,
|
||||
)
|
||||
from hub.schemas.downloads import TransportCapabilities
|
||||
from hub.utils.download_registry import (
|
||||
|
|
@ -547,8 +548,9 @@ async def lifespan(app: FastAPI):
|
|||
threading.Thread(target = _warm_rag_embedder, daemon = True, name = "rag-embedder-warm").start()
|
||||
|
||||
# Idle auto-unload loop (no-op unless the OpenAI auto-unload TTL is set).
|
||||
from core.inference.llama_keepwarm import idle_unload_loop
|
||||
from core.inference.llama_keepwarm import idle_unload_loop, sweep_slot_save_dir
|
||||
|
||||
sweep_slot_save_dir()
|
||||
app.state.idle_unload_task = asyncio.create_task(idle_unload_loop())
|
||||
|
||||
# Initialize RSA key pair for API key encryption (external providers).
|
||||
|
|
@ -573,7 +575,7 @@ async def lifespan(app: FastAPI):
|
|||
print("DEFAULT ADMIN ACCOUNT CREATED")
|
||||
print(f" username: {storage.DEFAULT_ADMIN_USERNAME}")
|
||||
print(f" password saved to: {bootstrap_path}")
|
||||
print(" Open the Studio UI to sign in and change it.")
|
||||
print(" Open the Unsloth UI to sign in and change it.")
|
||||
print("=" * 60 + "\n")
|
||||
else:
|
||||
app.state.bootstrap_password = (
|
||||
|
|
@ -612,6 +614,22 @@ app = FastAPI(
|
|||
lifespan = lifespan,
|
||||
)
|
||||
|
||||
# The MCP surface is opt-in because it can start GPU jobs and write model
|
||||
# artifacts. Mount it only when explicitly enabled by the Unsloth process.
|
||||
if os.environ.get("UNSLOTH_STUDIO_ENABLE_MCP") == "1":
|
||||
from fastmcp.utilities.lifespan import combine_lifespans
|
||||
|
||||
from mcp_server import BearerTokenMiddleware, create_studio_mcp
|
||||
|
||||
_studio_mcp_app = create_studio_mcp().http_app(path = "/")
|
||||
_studio_mcp_lifespan = _studio_mcp_app.lifespan
|
||||
_mcp_token = os.environ.get("UNSLOTH_STUDIO_MCP_TOKEN")
|
||||
if not _mcp_token:
|
||||
raise RuntimeError("UNSLOTH_STUDIO_MCP_TOKEN is required when MCP is enabled")
|
||||
_studio_mcp_app = BearerTokenMiddleware(_studio_mcp_app, _mcp_token)
|
||||
app.router.lifespan_context = combine_lifespans(lifespan, _studio_mcp_lifespan)
|
||||
app.mount("/mcp", _studio_mcp_app)
|
||||
|
||||
from loggers.config import LogConfig
|
||||
from loggers.handlers import LoggingMiddleware
|
||||
|
||||
|
|
@ -752,6 +770,7 @@ _BODY_PROTECTED_PREFIXES = (
|
|||
"/api/settings",
|
||||
"/api/train",
|
||||
"/api/export",
|
||||
"/mcp",
|
||||
)
|
||||
_DATASET_UPLOAD_PASSTHROUGH_PREFIX = "/api/datasets/upload"
|
||||
_DATA_RECIPE_UNSTRUCTURED_UPLOAD_PASSTHROUGH_PREFIX = (
|
||||
|
|
@ -956,7 +975,7 @@ app.include_router(training_router, prefix = "/api/train", tags = ["training"])
|
|||
app.include_router(models_router, prefix = "/api/models", tags = ["models"])
|
||||
app.include_router(chat_history_router, prefix = "/api/chat", tags = ["chat"])
|
||||
app.include_router(inference_router, prefix = "/api/inference", tags = ["inference"])
|
||||
# Studio-only inference endpoints (cancel, etc.) are NOT exposed on the /v1
|
||||
# Unsloth-only inference endpoints (cancel, etc.) are NOT exposed on the /v1
|
||||
# OpenAI-compat prefix below.
|
||||
app.include_router(inference_studio_router, prefix = "/api/inference", tags = ["inference"])
|
||||
|
||||
|
|
@ -975,6 +994,7 @@ app.include_router(rag_router, prefix = "/api/rag", tags = ["rag"])
|
|||
app.include_router(training_history_router, prefix = "/api/train", tags = ["training-history"])
|
||||
app.include_router(hub_inventory_router, prefix = "/api/hub", tags = ["hub"])
|
||||
app.include_router(hub_datasets_router, prefix = "/api/hub/datasets", tags = ["hub"])
|
||||
app.include_router(hub_token_router, prefix = "/api/hub", tags = ["hub"])
|
||||
|
||||
# Re-wrap client-error responses on the /v1/* surface into OpenAI/Anthropic
|
||||
# error envelopes; non-/v1 paths keep FastAPI's default {"detail": ...} shape.
|
||||
|
|
@ -1063,7 +1083,7 @@ def studio_install_source(_current_subject: str = Depends(get_current_subject)):
|
|||
|
||||
@app.get("/api/studio/update-status")
|
||||
def studio_update_status(_current_subject: str = Depends(get_current_subject)):
|
||||
"""Return source-aware manual update status for browser-served Studio."""
|
||||
"""Return source-aware manual update status for browser-served Unsloth."""
|
||||
return get_studio_update_status(UNSLOTH_VERSION)
|
||||
|
||||
|
||||
|
|
@ -1131,17 +1151,35 @@ def _get_cached_system_gpu_info(logger) -> dict[str, Any]:
|
|||
util = util_devices.get(idx, {})
|
||||
|
||||
total_vram = util.get("vram_total_gb") or dev.get("memory_total_gb") or 0
|
||||
used_vram = util.get("vram_used_gb") or 0
|
||||
# Keep None (usage unknown, e.g. Windows ROCm perf counter) so the UI
|
||||
# shows unknown, not a fabricated 0 used / full free.
|
||||
used_vram = util.get("vram_used_gb")
|
||||
|
||||
enriched_dev = dict(dev)
|
||||
enriched_dev["vram_used_gb"] = used_vram
|
||||
enriched_dev["vram_free_gb"] = round(total_vram - used_vram, 2) if total_vram else 0
|
||||
enriched_dev["vram_free_gb"] = (
|
||||
round(total_vram - used_vram, 2) if total_vram and used_vram is not None else None
|
||||
)
|
||||
enriched_dev["vram_utilization_pct"] = util.get("vram_utilization_pct")
|
||||
enriched_devices.append(enriched_dev)
|
||||
|
||||
# Whether GGUF loads accept an explicit gpu_ids pick: /load and
|
||||
# /validate 400 picks on XPU hosts (no visibility mask speaks torch-xpu
|
||||
# ordinals) and on Vulkan-only builds (--device pins ggml's own
|
||||
# ordinals), so the picker must not offer them.
|
||||
try:
|
||||
from core.inference.llama_cpp import LlamaCppBackend
|
||||
from utils.hardware import DeviceType, get_device
|
||||
gpu_ids_supported = (
|
||||
get_device() != DeviceType.XPU and not LlamaCppBackend._is_vulkan_backend()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not resolve gpu_ids support: {e}")
|
||||
gpu_ids_supported = True
|
||||
gpu_info = {
|
||||
"available": visibility_info.get("available", False),
|
||||
"devices": enriched_devices,
|
||||
"gguf_gpu_ids_supported": gpu_ids_supported,
|
||||
}
|
||||
_system_gpu_cache = (time.monotonic(), gpu_info)
|
||||
return gpu_info
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue