Merge branch 'unslothai:main' into fix-oom-labels-vulkan-6414

This commit is contained in:
Leo Borcherding 2026-07-23 16:52:36 -05:00 committed by GitHub
commit 79eace774d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
327 changed files with 26176 additions and 4815 deletions

View file

@ -1,18 +1,16 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Runs installer parity and autostart opt-out tests on Windows and macOS.
# Runs installer parity and autostart opt-out tests across all three platforms.
#
# Why: that test is the guard that install.sh and install.ps1 stay in
# sync, but today it only runs on ubuntu-latest (auto-discovered by
# studio-backend-ci.yml's "Repo tests (CPU)" job). The test reads both
# installer scripts, and on Windows Path.read_text() defaults to the
# cp1252 locale encoding, so a non-cp1252 byte in install.sh (it already
# contains a U+274C) raises UnicodeDecodeError there even though Linux and
# macOS default to UTF-8. The reads were pinned to encoding="utf-8" in
# #6166; this job keeps that from silently regressing by exercising the
# test on the platforms it claims parity for. Pure pytest, no GPU,
# sub-second, so the matrix is cheap.
# Why: the parity test guards that install.sh and install.ps1 stay in sync.
# It originally ran only on ubuntu-latest through studio-backend-ci.yml.
# On Windows, Path.read_text() defaults to the cp1252 locale encoding, so a
# non-cp1252 byte in install.sh raises UnicodeDecodeError even though Linux
# and macOS default to UTF-8. The reads were pinned to encoding="utf-8" in
# #6166; this matrix keeps that from silently regressing. Pure pytest, no GPU,
# sub-second, so the matrix is cheap. Linux also runs the POSIX rollback test
# under dash, matching the supported curl-to-sh installer path.
name: Cross-platform parity
@ -23,6 +21,8 @@ on:
- 'install.ps1'
- 'tests/test_installer_skip_autostart.py'
- 'tests/python/test_cross_platform_parity.py'
- 'tests/sh/test_install_rollback_lifecycle.sh'
- 'tests/studio/test_install_rollback_lifecycle.ps1'
- '.github/workflows/cross-platform-parity-ci.yml'
push:
branches: [main]
@ -31,6 +31,8 @@ on:
- 'install.ps1'
- 'tests/test_installer_skip_autostart.py'
- 'tests/python/test_cross_platform_parity.py'
- 'tests/sh/test_install_rollback_lifecycle.sh'
- 'tests/studio/test_install_rollback_lifecycle.ps1'
- '.github/workflows/cross-platform-parity-ci.yml'
workflow_dispatch:
@ -47,7 +49,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest]
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 10
steps:
@ -67,3 +69,10 @@ jobs:
tests/python/test_cross_platform_parity.py
tests/test_installer_skip_autostart.py
-q
- name: PowerShell rollback lifecycle tests
if: runner.os == 'Windows'
shell: pwsh
run: pwsh -NoProfile -File tests/studio/test_install_rollback_lifecycle.ps1
- name: POSIX rollback lifecycle tests
if: runner.os == 'Linux'
run: sh tests/sh/test_install_rollback_lifecycle.sh

View file

@ -231,6 +231,15 @@ jobs:
mkdir -p logs/playwright_extra
python tests/studio/playwright_extra_ui.py
- name: UI font size scaling regression (Playwright)
env:
BASE_URL: http://127.0.0.1:18894
STUDIO_PW: ${{ env.STUDIO_EXTRA_NEW_PW }}
PW_ART_DIR: logs/playwright_fontscale
run: |
mkdir -p logs/playwright_fontscale
python tests/studio/playwright_ui_font_scale.py
- name: Stop second Unsloth
if: always()
run: |
@ -352,6 +361,7 @@ jobs:
logs/playwright
logs/playwright-permissions-*
logs/playwright_extra
logs/playwright_fontscale
logs/playwright_modelcfg
logs/playwright_ime
logs/studio-permissions-*.log

View file

@ -1416,13 +1416,82 @@ exit 0
$suffix++
$candidate = Join-Path $StudioHome "unsloth_studio.rollback.$stamp.$PID.$suffix"
}
Move-Item -LiteralPath $ExistingDir -Destination $candidate -ErrorAction Stop
$script:StudioVenvRollbackDir = $candidate
$script:StudioVenvRollbackTarget = $ExistingDir
$script:StudioVenvRollbackActive = $true
# Publish the rollback state before the atomic rename so interruption
# cannot land after Move-Item but before cleanup knows where the old venv went.
try {
Move-Item -LiteralPath $ExistingDir -Destination $candidate -ErrorAction Stop
} catch {
# A collision or ordinary rename failure leaves the original in place.
# Keep state active only when the rename happened before interruption.
if (Test-Path -LiteralPath $ExistingDir) {
$script:StudioVenvRollbackActive = $false
$script:StudioVenvRollbackDir = $null
}
throw
}
substep "previous environment preserved for rollback"
}
function Remove-StudioVenvTreeWithRetry {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Label
)
$lastError = $null
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
} catch {
$lastError = $_.Exception.Message
}
if (-not (Test-Path -LiteralPath $Path)) { return $true }
if ($attempt -lt 3) { Start-Sleep -Milliseconds (250 * $attempt) }
}
Write-Host "[WARN] Could not remove $Label at $Path" -ForegroundColor Yellow
if ($lastError) { Write-Host " $lastError" -ForegroundColor Yellow }
return $false
}
function Test-StudioVenvRollbackMustBePreserved {
param([Parameter(Mandatory = $true)][System.IO.FileSystemInfo]$Rollback)
# Preserve anything outside the installer's timestamp.PID[.suffix] format.
if ($Rollback.Name -notmatch '^unsloth_studio\.rollback\.[0-9]{14}\.([0-9]+)(?:\.[0-9]+)?$') {
return $true
}
$ownerPid = 0
if (-not [int]::TryParse($Matches[1], [ref]$ownerPid)) { return $true }
if ($ownerPid -eq $PID) { return $true }
return $null -ne (Get-Process -Id $ownerPid -ErrorAction SilentlyContinue)
}
function Remove-StaleStudioVenvRollbacks {
try {
$rollbacks = @(
Get-ChildItem -LiteralPath $StudioHome -Directory -Force -ErrorAction Stop |
Where-Object { $_.Name -like 'unsloth_studio.rollback.*' }
)
} catch {
Write-Host "[WARN] Could not inspect stale environment rollbacks in $StudioHome" -ForegroundColor Yellow
Write-Host " $($_.Exception.Message)" -ForegroundColor Yellow
return
}
foreach ($rollback in $rollbacks) {
if (($rollback.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
Write-Host "[WARN] Refusing to remove rollback reparse point $($rollback.FullName)" -ForegroundColor Yellow
continue
}
# A concurrent installer may have moved its live venv aside. The PID
# in the generated name keeps this run from deleting its rescue copy.
if (Test-StudioVenvRollbackMustBePreserved -Rollback $rollback) { continue }
if (Remove-StudioVenvTreeWithRetry -Path $rollback.FullName -Label "stale environment rollback") {
substep "removed stale environment rollback $($rollback.Name)"
}
}
}
function Restore-StudioVenvRollback {
if (-not $script:StudioVenvRollbackActive) { return }
$backup = $script:StudioVenvRollbackDir
@ -1434,7 +1503,9 @@ exit 0
substep "restoring previous environment after failed install..." "Yellow"
try {
if (Test-Path -LiteralPath $target) {
Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction SilentlyContinue
if (-not (Remove-StudioVenvTreeWithRetry -Path $target -Label "incomplete environment")) {
throw "Could not remove incomplete environment at $target"
}
}
Move-Item -LiteralPath $backup -Destination $target -Force -ErrorAction Stop
substep "restored previous environment"
@ -1449,13 +1520,17 @@ exit 0
function Complete-StudioVenvRollback {
if (-not $script:StudioVenvRollbackActive) { return }
$backup = $script:StudioVenvRollbackDir
if ($backup -and (Test-Path -LiteralPath $backup)) {
Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction SilentlyContinue
}
# The replacement is committed. Disable restoration before deleting the
# backup so interruption cannot restore a partially deleted environment.
$script:StudioVenvRollbackActive = $false
$script:StudioVenvRollbackDir = $null
if ($backup -and (Test-Path -LiteralPath $backup)) {
Remove-StudioVenvTreeWithRetry -Path $backup -Label "environment rollback" | Out-Null
}
}
$studioVenvReplacementCommitted = $false
try {
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
@ -2272,7 +2347,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 (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.4" "unsloth-zoo>=2026.7.4" }
$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.5" "unsloth-zoo>=2026.7.6" }
if ($baseInstallExit -eq 0) {
# Resolve pydantic WITH deps so pip pins pydantic-core
# to the matching version (no-torch-runtime.txt below
@ -2286,7 +2361,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.4" "unsloth-zoo>=2026.7.4" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6" }
}
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
@ -2360,7 +2435,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.4" "unsloth-zoo>=2026.7.4" }
$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.5" "unsloth-zoo>=2026.7.6" }
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 }
@ -2372,7 +2447,7 @@ exit 0
}
}
} elseif ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.7.4" "unsloth-zoo>=2026.7.4" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6" }
} else {
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth" { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
}
@ -2400,7 +2475,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.4" "unsloth>=2026.7.4" --torch-backend=auto }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.7.6" "unsloth>=2026.7.5" --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)
@ -2688,6 +2763,13 @@ exit 0
}
Refresh-SessionPath # sync current session with registry
Complete-StudioVenvRollback
$studioVenvReplacementCommitted = $true
Remove-StaleStudioVenvRollbacks
} finally {
if (-not $studioVenvReplacementCommitted) {
Restore-StudioVenvRollback
}
}
# Env-mode session export AFTER Refresh-SessionPath; otherwise a legacy
# User PATH entry (Machine > User > current $env:Path) would win.

View file

@ -475,14 +475,20 @@ _start_studio_venv_replacement() {
_stamp=$(date +%Y%m%d%H%M%S 2>/dev/null || echo "time")
_candidate="$STUDIO_HOME/unsloth_studio.rollback.$_stamp.$$"
_suffix=0
while [ -e "$_candidate" ]; do
while [ -e "$_candidate" ] || [ -L "$_candidate" ]; do
_suffix=$((_suffix + 1))
_candidate="$STUDIO_HOME/unsloth_studio.rollback.$_stamp.$$.$_suffix"
done
mv "$_existing_dir" "$_candidate"
_VENV_ROLLBACK_DIR="$_candidate"
_VENV_ROLLBACK_TARGET="$_existing_dir"
_VENV_ROLLBACK_ACTIVE=true
# Publish the rollback state before the atomic rename so a signal cannot
# land after mv but before the exit handlers know where the old venv went.
if ! mv "$_existing_dir" "$_candidate"; then
_VENV_ROLLBACK_ACTIVE=false
_VENV_ROLLBACK_DIR=""
return 1
fi
substep "previous environment preserved for rollback"
}
@ -503,13 +509,68 @@ _restore_studio_venv_replacement() {
fi
}
_commit_studio_venv_replacement() {
[ "$_VENV_ROLLBACK_ACTIVE" = true ] || return 0
if [ -n "$_VENV_ROLLBACK_DIR" ] && [ -d "$_VENV_ROLLBACK_DIR" ]; then
rm -rf "$_VENV_ROLLBACK_DIR" || true
_studio_venv_rollback_must_be_preserved() {
_rollback_name=${1##*/}
_rollback_metadata=${_rollback_name#unsloth_studio.rollback.}
_rollback_stamp=${_rollback_metadata%%.*}
_rollback_process=${_rollback_metadata#*.}
# Preserve anything outside the installer's timestamp.PID[.suffix] format.
[ "$_rollback_process" != "$_rollback_metadata" ] || return 0
case "$_rollback_stamp" in
time) ;;
''|*[!0-9]*) return 0 ;;
*) [ "${#_rollback_stamp}" -eq 14 ] || return 0 ;;
esac
_rollback_pid=${_rollback_process%%.*}
case "$_rollback_pid" in
''|*[!0-9]*) return 0 ;;
esac
_rollback_suffix=${_rollback_process#*.}
if [ "$_rollback_suffix" != "$_rollback_process" ]; then
case "$_rollback_suffix" in ''|*[!0-9]*) return 0 ;; esac
fi
_VENV_ROLLBACK_ACTIVE=false
_VENV_ROLLBACK_DIR=""
kill -0 "$_rollback_pid" 2>/dev/null
}
_prune_stale_studio_venv_rollbacks() {
for _stale_rollback in "$STUDIO_HOME"/unsloth_studio.rollback.*; do
[ -d "$_stale_rollback" ] || continue
if [ -L "$_stale_rollback" ]; then
echo "⚠️ Refusing to remove rollback symlink $_stale_rollback" >&2
continue
fi
# A concurrent installer may have moved its live venv aside. The PID in
# the generated name keeps this successful run from deleting its rescue copy.
_studio_venv_rollback_must_be_preserved "$_stale_rollback" && continue
if rm -rf "$_stale_rollback"; then
substep "removed stale environment rollback ${_stale_rollback##*/}"
else
echo "⚠️ Could not remove stale environment rollback $_stale_rollback" >&2
fi
done
}
_commit_studio_venv_replacement() {
if [ "$_VENV_ROLLBACK_ACTIVE" = true ]; then
_rollback_to_remove="$_VENV_ROLLBACK_DIR"
# The new environment is already committed. Clear the restore state
# before deletion so an interrupt cannot replace it with a half-deleted backup.
_VENV_ROLLBACK_ACTIVE=false
_VENV_ROLLBACK_DIR=""
if [ -n "$_rollback_to_remove" ] && [ -d "$_rollback_to_remove" ]; then
if ! rm -rf "$_rollback_to_remove"; then
echo "⚠️ Could not remove environment rollback $_rollback_to_remove" >&2
fi
fi
fi
# Only prune older orphaned copies after the replacement has succeeded, so
# an interrupted install never discards the last known-good environment.
_prune_stale_studio_venv_rollbacks
}
_cleanup_install_temporaries() {
[ -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
}
_on_install_exit() {
@ -517,15 +578,28 @@ _on_install_exit() {
if [ "$_status" -ne 0 ]; then
_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
_cleanup_install_temporaries
exit "$_status"
}
_on_install_signal() {
_signal_status="$1"
# EXIT is disabled to avoid a second cleanup pass. Ignore further termination
# signals until the old environment is back in place.
trap - EXIT
trap '' HUP INT TERM
_restore_studio_venv_replacement
_cleanup_install_temporaries
exit "$_signal_status"
}
# 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
trap '_on_install_signal 129' HUP
trap '_on_install_signal 130' INT
trap '_on_install_signal 143' TERM
# ── Helper: download a URL to a file (supports curl and wget) ──
download() {
@ -3397,7 +3471,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.4" "unsloth-zoo>=2026.7.4"
"unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6"
# 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.
@ -3414,7 +3488,7 @@ if [ "$_MIGRATED" = true ]; then
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.4" "unsloth-zoo>=2026.7.4" ${_MLX_LM_EXCLUDE_ARG:-}
"unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6" ${_MLX_LM_EXCLUDE_ARG:-}
[ -n "$_UNSLOTH_TORCH_OVERRIDES" ] && rm -f "$_UNSLOTH_TORCH_OVERRIDES"
_UNSLOTH_TORCH_OVERRIDES=""
fi
@ -3638,7 +3712,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; 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.4" "unsloth-zoo>=2026.7.4"
"unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6"
# 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
@ -3657,7 +3731,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
run_install_cmd_retry "install unsloth (local)" uv pip install --python "$_VENV_PY" \
${_UNSLOTH_TORCH_OVERRIDES:+--overrides "$_UNSLOTH_TORCH_OVERRIDES"} \
--upgrade-package unsloth "unsloth>=2026.7.4" "unsloth-zoo>=2026.7.4"
--upgrade-package unsloth "unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6"
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..."
@ -3685,7 +3759,7 @@ else
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.4" "unsloth>=2026.7.4" --torch-backend=auto
run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.7.6" "unsloth>=2026.7.5" --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..."

View file

@ -25,7 +25,7 @@ classifiers = [
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
dependencies = [
"typer",
"typer>=0.12.0",
"rich",
"pydantic",
"pyyaml",
@ -74,7 +74,7 @@ triton = [
]
huggingfacenotorch = [
"unsloth_zoo>=2026.7.4",
"unsloth_zoo>=2026.7.6",
"wheel>=0.42.0",
"packaging",
"numpy",
@ -95,7 +95,7 @@ huggingfacenotorch = [
]
huggingface = [
"unsloth[huggingfacenotorch]",
"unsloth_zoo>=2026.7.4",
"unsloth_zoo>=2026.7.6",
"torchvision",
"unsloth[triton]",
]
@ -580,7 +580,7 @@ colab-ampere-torch220 = [
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
]
colab-new = [
"unsloth_zoo>=2026.7.4",
"unsloth_zoo>=2026.7.6",
"packaging",
"tyro",
"transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0",

71
scripts/build_whisper_cpp.sh Executable file
View file

@ -0,0 +1,71 @@
#!/bin/sh
# Build whisper.cpp's whisper-server for Studio's GGUF dictation engine.
#
# Installs into the managed Studio home so the backend's binary discovery
# (core/inference/stt_ggml_sidecar.py::find_whisper_server_binary) picks it up:
# <UNSLOTH_STUDIO_HOME>/whisper.cpp/build/bin/whisper-server (custom home)
# ~/.unsloth/whisper.cpp/build/bin/whisper-server (default)
#
# Usage:
# ./scripts/build_whisper_cpp.sh # build the pinned tag
# WHISPER_CPP_TAG=v1.9.0 ./scripts/build_whisper_cpp.sh
#
# Requires: git, cmake, a C/C++ toolchain (the same prerequisites as a
# llama.cpp source build). GPU backends are auto-detected by whisper.cpp's
# CMake (Metal on macOS; set GGML_CUDA=1 to force a CUDA build on Linux).
set -eu
WHISPER_CPP_SOURCE="${WHISPER_CPP_SOURCE:-https://github.com/ggml-org/whisper.cpp}"
WHISPER_CPP_TAG="${WHISPER_CPP_TAG:-v1.9.1}"
STUDIO_HOME="${UNSLOTH_STUDIO_HOME:-${STUDIO_HOME:-}}"
CUSTOM_STUDIO_HOME=false
if [ -n "$STUDIO_HOME" ]; then
CUSTOM_STUDIO_HOME=true
INSTALL_DIR="$STUDIO_HOME/whisper.cpp"
else
INSTALL_DIR="$HOME/.unsloth/whisper.cpp"
fi
command -v git >/dev/null 2>&1 || { echo "ERROR: git is required" >&2; exit 1; }
command -v cmake >/dev/null 2>&1 || { echo "ERROR: cmake is required" >&2; exit 1; }
# Same policy as studio/setup.sh's _assert_studio_owned_or_absent: never delete
# a directory under a custom Studio home unless Studio itself created it (the
# marker file below). Protects a user-managed whisper.cpp/src from rm -rf.
STUDIO_OWNED_MARKER=".unsloth-studio-owned"
if [ "$CUSTOM_STUDIO_HOME" = true ] && [ -e "$INSTALL_DIR" ] && \
[ ! -f "$INSTALL_DIR/$STUDIO_OWNED_MARKER" ]; then
echo "ERROR: $INSTALL_DIR already exists and is not marked as an Unsloth-owned whisper.cpp build tree." >&2
echo " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." >&2
exit 1
fi
echo "==> Building whisper.cpp ($WHISPER_CPP_TAG) into $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
: > "$INSTALL_DIR/$STUDIO_OWNED_MARKER"
if [ ! -d "$INSTALL_DIR/src/.git" ]; then
rm -rf "$INSTALL_DIR/src"
git clone --depth 1 --branch "$WHISPER_CPP_TAG" "$WHISPER_CPP_SOURCE" "$INSTALL_DIR/src"
else
git -C "$INSTALL_DIR/src" fetch --depth 1 origin "$WHISPER_CPP_TAG"
git -C "$INSTALL_DIR/src" checkout FETCH_HEAD
fi
CMAKE_FLAGS="-DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF"
if [ "${GGML_CUDA:-0}" = "1" ]; then
CMAKE_FLAGS="$CMAKE_FLAGS -DGGML_CUDA=ON"
fi
# shellcheck disable=SC2086
cmake -S "$INSTALL_DIR/src" -B "$INSTALL_DIR/src/build" $CMAKE_FLAGS
NCPU="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)"
cmake --build "$INSTALL_DIR/src/build" --config Release --target whisper-server -j"$NCPU"
mkdir -p "$INSTALL_DIR/build/bin"
cp "$INSTALL_DIR/src/build/bin/whisper-server" "$INSTALL_DIR/build/bin/whisper-server"
echo "==> Installed $INSTALL_DIR/build/bin/whisper-server"
"$INSTALL_DIR/build/bin/whisper-server" --help >/dev/null 2>&1 && echo "==> Binary runs OK"

View file

@ -1561,6 +1561,62 @@
"severity": "CRITICAL",
"evidence": "L111: out = extract_audio_info(msgs({\"type\": \"audio\", key: \"/tmp/a.wav\"})) sha256:2efe23ffbe2b91b8403aec9b700736919b59e5ca770f8e1f5501651b44b7d398",
"evidence_hash": "d416b79dd17b24214f3f7653ac01354507d7bf0fc464dee30a4a4b8998f063ba"
},
{
"package": "openai",
"file": "openai/_base_client.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
"evidence": "L274: while True: sha256:90a38e5c1e26893c7c273354143612640e9a9c0f079d3e2b60612d79f24e80a6",
"evidence_hash": "1022e8e8649436ec64a98a9d9141d085452c49549fd2157b0278fc369a83ac66"
},
{
"package": "openai",
"file": "openai/auth/_workload.py",
"check": "Accesses cloud metadata/IMDS AND makes network calls",
"severity": "CRITICAL",
"evidence": "IMDS: L97: url = \"http://169.254.169.254/metadata/identity/oauth2/token\" | L150: url = \"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity\"\nNetwork: L78: http_client: httpx.Client | None = None, | L109: with httpx.Client() as client: | L134: http_client: httpx.Client | None = None, | L156: with httpx.Client() as client: | L251: exchange_client = DefaultHttpx2Client(follow_redirects=False) if self._use_httpx2 else httpx.Client()",
"evidence_hash": "9717e51cb961dc14c458955d91a1e48e3753997346ecea0106bded3a8d64bfe0"
},
{
"package": "openai",
"file": "openai/resources/beta/responses/responses.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
"evidence": "L4000: while True: sha256:f8ab538118daba9ec06e27399dbdc90a4521c3390e6a47a6348a1f180a83effd",
"evidence_hash": "31481ea83c687acc27144d72d3832d4fb98dd1c79fb5e0ddd85080de95997b9f"
},
{
"package": "openai",
"file": "openai/resources/realtime/realtime.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
"evidence": "L311: while True: sha256:5b63313072aae9ca28677e03426513ccf12221e4f4e0ea6c31efbe09790633b5",
"evidence_hash": "05e1af469d651b51673763a7c4cdf759af9472fb627b7b470adc28cc237bd650"
},
{
"package": "openai",
"file": "openai/resources/responses/responses.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
"evidence": "L3951: while True: sha256:d68ef896bf0743ca430cfacb9a3353da1f3b9c51c3a21b6450a07a32b55aa2ac",
"evidence_hash": "160eecdd79b521bffbe8476f782b69a0724c35d1b19376a7600807165fd54f9f"
},
{
"package": "unsloth-zoo",
"file": "tests/test_gemma4_forced_float32_ple_dtype.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
"evidence": "Obfusc: L277: compile(rewritten + _GEMMA4_PLE_CAST_HELPER, \"<gemma4-ple-generated>\", \"exec\") | L440: compile(on, \"<gemma4-ple-append>\", \"exec\") | L468: compile(generated, \"<gemma4-ple-crosspath>\", \"exec\")\nExec: L19: exec(_GEMMA4_PLE_CAST_HELPER, namespace)",
"evidence_hash": "a85e24d8e7c431563cbd83b70f91a3b971abde0f37083d68e70984147960cc70"
},
{
"package": "unsloth-zoo",
"file": "tests/test_vision_collator_audio.py",
"check": "Writes to /tmp and executes (staged dropper)",
"severity": "CRITICAL",
"evidence": "L111: out = extract_audio_info(msgs({\"type\": \"audio\", key: \"/tmp/a.wav\"})) sha256:022f81dd21acfc6a35a058de96132834c218404a9e37b3d09a7768a8c8f6c728",
"evidence_hash": "2d1e75446af120d9133a42aa8af426a839d3434d9dc109cc1d6c1b22ca1ddb75"
}
]
}

View file

@ -236,6 +236,10 @@ def prompt_for_password_change(
out.write(f"Password must be at least {min_length} characters; try again.\n")
out.flush()
continue
if any(ch.isspace() for ch in new_password):
out.write("Password cannot contain spaces; try again.\n")
out.flush()
continue
if is_current_password(new_password):
out.write(
"New password must differ from the current bootstrap password; try again.\n"

View file

@ -27,7 +27,6 @@ from .constants import (
)
from .parse import apply_update, coerce_event, parse_log_message
from .types import Job
from .worker import run_job_process
from loggers import get_logger
logger = get_logger(__name__)
@ -169,12 +168,18 @@ class JobManager:
native_path_secret_removed_for_child_start,
run_without_native_path_secret,
)
from utils.hf_cache_settings import child_environment_for_spawn, get_hf_cache_paths
with native_path_secret_removed_for_child_start():
cache_env = get_hf_cache_paths().child_env({})
with (
child_environment_for_spawn(cache_env),
native_path_secret_removed_for_child_start(),
):
mp_q = _CTX.Queue()
proc = _CTX.Process(
target = run_without_native_path_secret,
args = (run_job_process,),
args = ("core.data_recipe.jobs.worker", "run_job_process", cache_env),
kwargs = {"event_queue": mp_q, "recipe": recipe, "run": run_payload},
daemon = True,
)

View file

@ -230,16 +230,20 @@ class ExportOrchestrator:
native_path_secret_removed_for_child_start,
run_without_native_path_secret,
)
from utils.hf_cache_settings import child_environment_for_spawn, get_hf_cache_paths
from .worker import run_export_process
cache_env = get_hf_cache_paths().child_env({})
with native_path_secret_removed_for_child_start():
with (
child_environment_for_spawn(cache_env),
native_path_secret_removed_for_child_start(),
):
self._cmd_queue = _CTX.Queue()
self._resp_queue = _CTX.Queue()
self._proc = _CTX.Process(
target = run_without_native_path_secret,
args = (run_export_process,),
args = ("core.export.worker", "run_export_process", cache_env),
kwargs = {
"cmd_queue": self._cmd_queue,
"resp_queue": self._resp_queue,

View file

@ -76,8 +76,14 @@ class AudioCodecManager:
if self._snac_model is not None:
return
from snac import SNAC
from utils.hf_cache_settings import active_hf_hub_cache
self._snac_model = SNAC.from_pretrained("hubertsiuzdak/snac_24khz").to(device).eval()
# Route weights to the selected cache; this can run in the main process.
self._snac_model = (
SNAC.from_pretrained("hubertsiuzdak/snac_24khz", cache_dir = active_hf_hub_cache())
.to(device)
.eval()
)
logger.info("Loaded SNAC codec (24kHz)")
def _load_bicodec(

View file

@ -579,7 +579,14 @@ def _swa_entry_from_layer_types(lt) -> Optional[object]:
def _fetch_swa_entry_from_hf(repo_id: str) -> Optional[object]:
try:
from huggingface_hub import hf_hub_download
cfg_path = hf_hub_download(repo_id, "config.json", repo_type = "model")
from utils.hf_cache_settings import active_hf_hub_cache
cfg_path = hf_hub_download(
repo_id,
"config.json",
repo_type = "model",
cache_dir = active_hf_hub_cache(),
)
with open(cfg_path) as f:
cfg = json.load(f)
except Exception:
@ -981,6 +988,7 @@ def _cached_hf_snapshot_file(
filename: str,
*,
expected_size: Optional[int] = None,
cache_dir: Optional[str] = None,
) -> Optional[str]:
"""Return a cached snapshot file even when HF's current-ref probe misses it."""
if not filename:
@ -989,8 +997,22 @@ def _cached_hf_snapshot_file(
if not parts or any(part in (".", "..") for part in parts):
return None
try:
from utils.models.model_config import _iter_hf_cache_snapshots
for snap in _iter_hf_cache_snapshots(repo_id):
if cache_dir is None:
from utils.models.model_config import _iter_hf_cache_snapshots
snapshots = _iter_hf_cache_snapshots(repo_id)
else:
from hub.utils.hf_cache_state import iter_active_repo_cache_dirs
snapshots = (
snapshot
for repo_dir in iter_active_repo_cache_dirs(
"model",
repo_id,
root = Path(cache_dir),
)
for snapshot in (repo_dir / "snapshots").glob("*")
if snapshot.is_dir()
)
for snap in snapshots:
candidate = snap.joinpath(*parts)
if not candidate.is_file():
continue
@ -1232,6 +1254,16 @@ def _snapshot_dir_of(path: str) -> Optional[Path]:
return None
def _hub_cache_dir_for_snapshot_path(path: Optional[str]) -> Optional[str]:
"""Return the HF Hub cache root that owns a snapshot-contained path."""
if not path:
return None
snapshot = _snapshot_dir_of(path)
if snapshot is None or snapshot.parent.name != "snapshots":
return None
return str(snapshot.parent.parent.parent)
def _companion_snapshot_sibling(
near_path: str, pick: Callable[[list[str]], Optional[str]]
) -> Optional[str]:
@ -5133,6 +5165,9 @@ class LlamaCppBackend:
touching the shared one; defaults to the shared event.
"""
cancel_event = cancel_event if cancel_event is not None else self._cancel_event
from utils.hf_cache_settings import get_hf_cache_paths
download_cache_dir = str(get_hf_cache_paths().hub_cache)
try:
import huggingface_hub # noqa: F401 -- presence check only
except ImportError:
@ -5228,7 +5263,11 @@ class LlamaCppBackend:
if not p.size:
continue
try:
cached_path = try_to_load_from_cache(hf_repo, p.path)
cached_path = try_to_load_from_cache(
hf_repo,
p.path,
cache_dir = download_cache_dir,
)
except Exception:
cached_path = None
if (
@ -5239,6 +5278,7 @@ class LlamaCppBackend:
hf_repo,
p.path,
expected_size = p.size,
cache_dir = download_cache_dir,
)
if isinstance(cached_path, str) and os.path.exists(cached_path):
try:
@ -5252,12 +5292,8 @@ class LlamaCppBackend:
total_download_bytes = max(0, total_bytes - already_cached_bytes)
if total_download_bytes > 0:
cache_dir = os.environ.get(
"HF_HUB_CACHE",
str(Path.home() / ".cache" / "huggingface" / "hub"),
)
Path(cache_dir).mkdir(parents = True, exist_ok = True)
free_bytes = shutil.disk_usage(cache_dir).free
Path(download_cache_dir).mkdir(parents = True, exist_ok = True)
free_bytes = shutil.disk_usage(download_cache_dir).free
total_gb = total_download_bytes / (1024**3)
free_gb = free_bytes / (1024**3)
@ -5275,7 +5311,7 @@ class LlamaCppBackend:
# surface the disk shortfall for the requested variant.
raise RuntimeError(
f"Not enough disk space to download {gguf_filename}. "
f"Only {free_gb:.1f} GB free in {cache_dir}"
f"Only {free_gb:.1f} GB free in {download_cache_dir}"
)
smaller = self._find_smallest_fitting_variant(
hf_repo,
@ -5306,7 +5342,7 @@ class LlamaCppBackend:
else:
raise RuntimeError(
f"Not enough disk space to download any variant. "
f"Only {free_gb:.1f} GB free in {cache_dir}"
f"Only {free_gb:.1f} GB free in {download_cache_dir}"
)
except RuntimeError:
raise
@ -5329,6 +5365,7 @@ class LlamaCppBackend:
cancel_event = cancel_event,
on_status = lambda m: logger.info(m),
force_download = force,
cache_dir = download_cache_dir,
)
for shard in gguf_extra_shards:
if cancel_event.is_set():
@ -5340,6 +5377,7 @@ class LlamaCppBackend:
hf_token,
cancel_event = cancel_event,
force_download = force,
cache_dir = download_cache_dir,
)
except Exception as e:
if isinstance(e, RuntimeError) and "Cancelled" in str(e):
@ -5385,6 +5423,12 @@ class LlamaCppBackend:
logger.info("Reusing cached %s: %s", label, cached)
return cached
from utils.hf_cache_settings import get_hf_cache_paths
companion_cache_dir = _hub_cache_dir_for_snapshot_path(near_path) or str(
get_hf_cache_paths().hub_cache
)
if _hub_download_in_flight(hf_repo):
logger.info("Skipping %s download while a hub download is active", label)
return None
@ -5419,7 +5463,7 @@ class LlamaCppBackend:
if target is None:
try:
from utils.models.model_config import _iter_hf_cache_snapshots
for snap in _iter_hf_cache_snapshots(hf_repo):
for snap in _iter_hf_cache_snapshots(hf_repo, companion_cache_dir):
rel_files = _gguf_snapshot_files(snap)
target = pick(rel_files)
if target is not None:
@ -5437,7 +5481,11 @@ class LlamaCppBackend:
# hf_hub_download with hf_repo would miss the canonical file and silently
# drop the companion. _cached_hf_snapshot_file scans every case variant.
if _hf_env_offline():
cached = _cached_hf_snapshot_file(hf_repo, target)
cached = _cached_hf_snapshot_file(
hf_repo,
target,
cache_dir = companion_cache_dir,
)
if cached:
logger.info("Resolved %s from local HF cache: %s", label, cached)
return cached
@ -5450,6 +5498,7 @@ class LlamaCppBackend:
target,
hf_token,
cancel_event = cancel_event,
cache_dir = companion_cache_dir,
)
except Exception as e:
logger.warning(f"Could not download {label}: {e}")
@ -5480,7 +5529,12 @@ class LlamaCppBackend:
near_path = near_path,
)
def _cached_repo_mtp_drafter(self, hf_repo: str) -> Optional[str]:
def _cached_repo_mtp_drafter(
self,
hf_repo: str,
*,
cache_dir: Optional[str] = None,
) -> Optional[str]:
"""A drafter already in this repo's local HF cache, reused offline when a
fresh copy can't be fetched. Prefers a repo-root ``mtp-*.gguf`` across all
cached snapshots; else an existing ``MTP/`` copy (any precision -- the
@ -5490,7 +5544,12 @@ class LlamaCppBackend:
roots: list[Path] = []
subdirs: list[Path] = []
for snap in _iter_hf_cache_snapshots(hf_repo): # newest first
snapshots = (
_iter_hf_cache_snapshots(hf_repo)
if cache_dir is None
else _iter_hf_cache_snapshots(hf_repo, cache_dir)
)
for snap in snapshots: # newest first
for f in sorted(_gguf_snapshot_files(snap)):
if _is_companion_gguf_path(f) and "mmproj" not in f.lower():
(roots if "/" not in f else subdirs).append(snap / f)
@ -5543,7 +5602,10 @@ class LlamaCppBackend:
# current cached file and refetch a changed one, so skip the probe here
# rather than pair new weights with a stale draft.
if _hf_env_offline():
cached = self._cached_repo_mtp_drafter(hf_repo)
cached = self._cached_repo_mtp_drafter(
hf_repo,
cache_dir = _hub_cache_dir_for_snapshot_path(near_path),
)
if cached:
logger.info(f"Reusing cached MTP drafter (offline): {cached}")
return cached

View file

@ -146,6 +146,7 @@ def _build_index() -> dict[str, _LocalGgufEntry]:
_is_hidden_model,
)
from utils.paths import legacy_hf_cache_dir, hf_default_cache_dir, lmstudio_model_dirs
from utils.hf_cache_settings import known_hf_hub_caches
index: dict[str, _LocalGgufEntry] = {}
seen_hf: set[str] = set()
@ -174,7 +175,12 @@ def _build_index() -> dict[str, _LocalGgufEntry]:
except Exception as exc:
logger.debug("auto-switch: ./models scan failed: %s", exc)
try:
for hf_dir in (_resolve_hf_cache_dir(), legacy_hf_cache_dir(), hf_default_cache_dir()):
for hf_dir in (
*known_hf_hub_caches(),
_resolve_hf_cache_dir(),
legacy_hf_cache_dir(),
hf_default_cache_dir(),
):
found += _scan_hf_once(hf_dir)
except Exception as exc:
logger.debug("auto-switch: HF cache scan failed: %s", exc)

View file

@ -217,10 +217,14 @@ class InferenceOrchestrator:
native_path_secret_removed_for_child_start,
run_without_native_path_secret,
)
from utils.hf_cache_settings import child_environment_for_spawn, get_hf_cache_paths
from .worker import run_inference_process
cache_env = get_hf_cache_paths().child_env({})
with native_path_secret_removed_for_child_start():
with (
child_environment_for_spawn(cache_env),
native_path_secret_removed_for_child_start(),
):
self._cmd_queue = _CTX.Queue()
self._resp_queue = _CTX.Queue()
self._cancel_event = _CTX.Event()
@ -228,7 +232,7 @@ class InferenceOrchestrator:
self._proc = _CTX.Process(
target = run_without_native_path_secret,
args = (run_inference_process,),
args = ("core.inference.worker", "run_inference_process", cache_env),
kwargs = {
"cmd_queue": self._cmd_queue,
"resp_queue": self._resp_queue,

View file

@ -0,0 +1,876 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""whisper.cpp (GGML/GGUF) speech-to-text sidecar for Studio dictation.
Runs the same curated Whisper checkpoints as the Transformers sidecar
(stt_sidecar.py) through whisper.cpp's `whisper-server`, ~2.5x faster at
identical quality on Apple Silicon and CPU because its Metal/CPU kernels run
the weights in f16 where PyTorch MPS requires fp32.
Owns a single `whisper-server` subprocess bound to 127.0.0.1 on an ephemeral
port; the model loads on demand, stays warm between dictations, and unloads
after the same keep-alive as the Transformers sidecar. Curated GGML checkpoints
are single files from `unslothai/whisper-*-GGUF`, downloaded directly rather
than through the Model Hub (whose variant planner only handles `.gguf` chat
layouts).
Binary discovery mirrors `_find_llama_server_binary`: env override, then managed
Studio home, then PATH. With no binary the engine is unavailable and dictation
falls back to the Transformers sidecar; `scripts/build_whisper_cpp.sh` installs
the binary.
"""
from __future__ import annotations
import io
import json
import os
import re
import shutil
import socket
import subprocess
import sys
import threading
import time
import urllib.request
import uuid
import wave
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator, Optional
from loggers import get_logger
from core.inference.stt_sidecar import (
STT_KEEP_ALIVE_SECONDS,
SttAudioDecodeError,
SttLanguageError,
SttLoadCancelledError,
SttModelIdError,
SttModelNotDownloadedError,
SttUnavailableError,
_decode_audio_bounded,
_known_whisper_languages,
_TARGET_SAMPLE_RATE,
_training_active,
normalize_whisper_language,
)
from utils.prebuilt.child_env import isolate_home, scrub_env, wsl_system_rocm_lib_dirs
from utils.prebuilt.runtime_libs import dedupe_existing_dirs
from utils.prebuilt.whisper_layout import lookup_marker
from utils.process_lifetime import adopt_pid, child_popen_kwargs, forget_pid
logger = get_logger(__name__)
# Curated GGML checkpoints, one repo per model. Keys match the Transformers
# sidecar's ids so the frontend reuses one picker; values are the single file
# inside each repo.
GGML_STT_REPOS: dict[str, str] = {
"tiny": "unslothai/whisper-tiny-GGUF",
"base": "unslothai/whisper-base-GGUF",
"small": "unslothai/whisper-small-GGUF",
"large-v3-turbo": "unslothai/whisper-large-v3-turbo-GGUF",
"large-v3": "unslothai/whisper-large-v3-GGUF",
}
GGML_STT_MODELS: dict[str, str] = {
"tiny": "whisper-tiny.bin",
"base": "whisper-base.bin",
"small": "whisper-small.bin",
"large-v3-turbo": "whisper-large-v3-turbo.bin",
"large-v3": "whisper-large-v3.bin",
}
DEFAULT_GGML_STT_MODEL = "small"
_SERVER_START_TIMEOUT_SECONDS = 120.0
_TRANSCRIBE_TIMEOUT_SECONDS = 600.0
class SttEngineUnavailableError(SttUnavailableError):
"""whisper-server is not installed; the GGUF dictation engine is off."""
def resolve_ggml_model_id(model: Optional[str]) -> str:
"""Validate a curated GGML model id. Custom repos are not supported here."""
if model is None or not str(model).strip():
return DEFAULT_GGML_STT_MODEL
normalized = str(model).strip()
if normalized in GGML_STT_MODELS:
return normalized
raise SttModelIdError(
f"STT model '{model}' is not a curated GGUF dictation model. "
f"Choose one of: {', '.join(GGML_STT_MODELS)}."
)
def _managed_whisper_cpp_dir() -> Path:
"""`<STUDIO_HOME>/whisper.cpp` in custom mode, else `~/.unsloth/whisper.cpp`.
Mirrors `managed_node_dir` / `_find_llama_server_binary` so managed runtimes
share one parent directory.
"""
legacy = Path.home() / ".unsloth" / "whisper.cpp"
try:
from utils.paths.storage_roots import studio_root
resolved = studio_root()
legacy_studio = Path.home() / ".unsloth" / "studio"
try:
is_legacy = resolved.resolve() == legacy_studio.resolve()
except (OSError, ValueError):
is_legacy = resolved == legacy_studio
return legacy if is_legacy else (resolved / "whisper.cpp")
except (ImportError, OSError, ValueError):
override = (
os.environ.get("UNSLOTH_STUDIO_HOME") or os.environ.get("STUDIO_HOME") or ""
).strip()
if override:
try:
return Path(override).expanduser().resolve() / "whisper.cpp"
except (OSError, ValueError):
return Path(override).expanduser() / "whisper.cpp"
return legacy
def find_whisper_server_binary() -> Optional[str]:
"""Locate the whisper-server binary.
Search order:
1. WHISPER_SERVER_PATH environment variable (direct path to binary)
2. UNSLOTH_WHISPER_CPP_PATH env var (custom whisper.cpp install dir)
3. managed dir: <STUDIO_HOME or ~/.unsloth>/whisper.cpp/{,build/bin/}whisper-server
4. whisper-server on PATH
"""
binary_name = "whisper-server.exe" if sys.platform == "win32" else "whisper-server"
def _layout_candidates(d: Path) -> list[Path]:
cands = [d / binary_name, d / "build" / "bin" / binary_name]
if sys.platform == "win32":
cands.append(d / "build" / "bin" / "Release" / binary_name)
return cands
env_path = os.environ.get("WHISPER_SERVER_PATH")
if env_path:
p = Path(env_path)
if _is_runnable(p):
return str(p)
custom_dir = os.environ.get("UNSLOTH_WHISPER_CPP_PATH")
if custom_dir:
for p in _layout_candidates(Path(custom_dir)):
if _is_runnable(p):
return str(p)
for p in _layout_candidates(_managed_whisper_cpp_dir()):
if _is_runnable(p):
return str(p)
return shutil.which(binary_name)
def _is_runnable(p: Path) -> bool:
"""A real whisper-server is an executable file. On Windows os.access(X_OK) is
effectively an existence check; on Unix it rejects a non-executable stub so a
half-written or wrong-mode file isn't mistaken for the server."""
return p.is_file() and (sys.platform == "win32" or os.access(p, os.X_OK))
def _whisper_install_marker(binary: str) -> Optional[dict]:
"""The prebuilt install marker above ``binary``, or None (source/custom builds)."""
return lookup_marker(binary).marker
def slim_runtime_intact(binary: str) -> bool:
"""True unless the marker says slim and the linked ggml runtime is missing
beside the server. New markers record the exact wired filenames
(linked_libraries), all of which must be present; legacy markers without the
field fall back to the per-OS core ggml name globs. A broken slim install
reads as engine-unavailable (reinstall via `unsloth studio update`), never a
crash at load."""
lookup = lookup_marker(binary)
marker = lookup.marker
if lookup.invalid or marker is None:
return not lookup.slim_collision
if not marker or marker.get("install_kind") != "slim":
return True
if lookup.authoritative:
valid = marker.get("component") == "whisper.cpp"
valid = valid and isinstance(marker.get("schema_version"), int)
valid = valid and all(
isinstance(marker.get(key), str) and marker[key]
for key in ("release_tag", "backend", "paired_llama_tag")
)
valid = valid and isinstance(marker.get("linked_libraries"), list)
valid = valid and bool(marker.get("linked_libraries"))
valid = valid and all(
isinstance(name, str) and name and Path(name).name == name
for name in marker["linked_libraries"]
)
if not valid:
return False
bin_dir = Path(binary).parent
linked = marker.get("linked_libraries")
if isinstance(linked, list) and linked and all(isinstance(name, str) for name in linked):
intact = all((bin_dir / name).is_file() for name in linked)
else:
if sys.platform == "win32":
required = ("ggml.dll", "ggml-base.dll")
elif sys.platform == "darwin":
required = ("libggml*.dylib", "libggml-base*.dylib")
else:
required = ("libggml.so*", "libggml-base.so*")
intact = all(any(p.is_file() for p in bin_dir.glob(pattern)) for pattern in required)
runtime_dirs = marker.get("linked_runtime_directories")
if intact and isinstance(runtime_dirs, list) and runtime_dirs:
intact = all(
isinstance(name, str)
and name
and (bin_dir / name).is_dir()
and any(path.is_file() for path in (bin_dir / name).rglob("*"))
for name in runtime_dirs
)
if intact and marker.get("backend") == "rocm":
expected_runtime_dirs = set() if sys.platform == "win32" else {"hipblaslt", "rocblas"}
intact = (
marker.get("runtime_wiring_version") == 2
and isinstance(runtime_dirs, list)
and set(runtime_dirs) == expected_runtime_dirs
)
if not intact:
logger.warning(
"slim whisper install is missing its linked ggml runtime at "
f"{bin_dir}; run `unsloth studio update` to reinstall it"
)
return intact
def is_available() -> bool:
binary = find_whisper_server_binary()
if binary is None:
return False
if not slim_runtime_intact(binary):
return False
try:
import av # noqa: F401
except Exception:
# No PyAV means every transcription 501s on decode.
return False
return True
def ensure_engine_available() -> str:
binary = find_whisper_server_binary()
if binary is None:
raise SttEngineUnavailableError(
"The local transcription runtime is not installed. Run "
"`unsloth studio update` to install it."
)
if not slim_runtime_intact(binary):
raise SttEngineUnavailableError(
"The local transcription runtime is missing its paired ggml "
"libraries. Run `unsloth studio update` to reinstall it."
)
return binary
# ---------------------------------------------------------------------------
# whisper-server child-process environment
# ---------------------------------------------------------------------------
# Build the whisper-server env: prepend the binary dir (co-located libs win, and
# a backstop where the loader ignores the rpath) and scrub secret-bearing vars the
# binary never needs. On WSL2 ROCm the system HIP libs go first, since a bundle's
# bare-metal HIP cannot drive /dev/dxg. A CUDA bundle ships libggml-cuda.so but not
# libcudart/libcublas (paired with the user's PyTorch), so add the
# CUDA-from-PyTorch runtime dirs the selection gated on, else the backend cannot
# resolve a runtime that lives only in wheels. Mirrors llama's binary_env(); the
# scrub/WSL/dedupe helpers live in utils.prebuilt.
# Module-level aliases keep the historical patch points for tests and callers.
_wsl_system_rocm_lib_dirs = wsl_system_rocm_lib_dirs
_dedupe_existing_dirs = dedupe_existing_dirs
def _whisper_server_child_env(binary: str) -> dict[str, str]:
"""Env for the whisper-server subprocess: secrets scrubbed, home/profile vars
repointed at a managed scratch dir (a downloaded binary must not see the real
home's token caches), co-located libs on the loader path, WSL system HIP first
on WSL2 ROCm."""
env = scrub_env(os.environ)
isolate_home(env, str(_managed_whisper_cpp_dir() / ".child_home"))
bin_dir = str(Path(binary).parent)
# A CUDA bundle needs the CUDA-from-PyTorch wheel dirs so libcudart/libcublas
# resolve at launch when they live only in site-packages/nvidia/*/lib. Placed
# after bin_dir so co-located libs still win; empty for other bundles.
cuda_runtime_dirs: list[str] = []
bundle_dir = Path(bin_dir)
has_cuda_module = any(
path.is_file()
for pattern in ("libggml-cuda.so*", "ggml-cuda*.dll")
for path in bundle_dir.glob(pattern)
)
if has_cuda_module:
try:
from utils.prebuilt.runtime_libs import python_runtime_dirs
cuda_runtime_dirs = python_runtime_dirs()
except Exception:
cuda_runtime_dirs = []
if sys.platform == "win32":
var, lead = "PATH", [bin_dir, *cuda_runtime_dirs]
elif sys.platform == "darwin":
var, lead = "DYLD_LIBRARY_PATH", [bin_dir]
else:
var, lead = "LD_LIBRARY_PATH", [bin_dir, *cuda_runtime_dirs]
wsl_rocm = _wsl_system_rocm_lib_dirs()
if wsl_rocm:
lead = [*wsl_rocm, bin_dir, *cuda_runtime_dirs]
env.setdefault("HSA_ENABLE_DXG_DETECTION", "1")
existing = [p for p in env.get(var, "").split(os.pathsep) if p]
env[var] = os.pathsep.join(_dedupe_existing_dirs([*lead, *existing]))
return env
# ---------------------------------------------------------------------------
# Model file download (single files; deliberately outside the Model Hub flow)
# ---------------------------------------------------------------------------
def _cached_model_path(model_id: str) -> Optional[str]:
"""Path of a fully downloaded GGML file in the shared HF cache, else None."""
from huggingface_hub import hf_hub_download
try:
return hf_hub_download(
repo_id = GGML_STT_REPOS[model_id],
filename = GGML_STT_MODELS[model_id],
local_files_only = True,
)
except Exception:
return None
class _GgmlDownloadState:
"""Tracks one background hf_hub_download of a curated GGML file."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._thread: Optional[threading.Thread] = None
self._model_id: Optional[str] = None
self._error: Optional[str] = None
self._total_bytes: Optional[int] = None
self._etag: Optional[str] = None
def status(self) -> dict:
with self._lock:
downloading = self._thread is not None and self._thread.is_alive()
return {
"downloading": downloading,
"model": self._model_id if downloading else None,
"error": self._error,
"bytes_total": self._total_bytes if downloading else None,
"bytes_done": self._incomplete_bytes() if downloading else None,
}
def _incomplete_bytes(self) -> Optional[int]:
"""Best-effort progress: size of the in-flight blob in the HF cache.
hf_hub_download writes ``blobs/<etag>.incomplete``; prefer this file's
etag, else the largest in-flight blob.
"""
try:
from huggingface_hub.constants import HF_HUB_CACHE
# Caller may hold the non-reentrant self._lock; bare reads are safe.
model_id = self._model_id
if not model_id:
return None
repo_dir = (
Path(HF_HUB_CACHE)
/ f"models--{GGML_STT_REPOS[model_id].replace('/', '--')}"
/ "blobs"
)
if not repo_dir.is_dir():
return None
etag = self._etag
if etag:
target = repo_dir / f"{etag}.incomplete"
if target.is_file():
return target.stat().st_size
sizes = [p.stat().st_size for p in repo_dir.glob("*.incomplete") if p.is_file()]
return max(sizes) if sizes else None
except Exception:
return None
def start(
self,
model_id: str,
hf_token: Optional[str] = None,
) -> None:
model_id = resolve_ggml_model_id(model_id)
with self._lock:
if self._thread is not None and self._thread.is_alive():
if self._model_id == model_id:
return
raise SttModelIdError(
f"Another GGUF dictation model ('{self._model_id}') is still "
"downloading; wait for it to finish."
)
self._model_id = model_id
self._error = None
self._total_bytes = None
self._etag = None
thread = threading.Thread(target = self._run, args = (model_id, hf_token), daemon = True)
self._thread = thread
thread.start()
def _run(self, model_id: str, hf_token: Optional[str]) -> None:
repo_id = GGML_STT_REPOS[model_id]
filename = GGML_STT_MODELS[model_id]
try:
from huggingface_hub import (
get_hf_file_metadata,
hf_hub_download,
hf_hub_url,
)
try:
# One HEAD request for the total and etag.
meta = get_hf_file_metadata(hf_hub_url(repo_id, filename), token = hf_token or None)
with self._lock:
self._total_bytes = meta.size
self._etag = meta.etag
except Exception:
pass
hf_hub_download(
repo_id = repo_id,
filename = filename,
token = hf_token or None,
)
except Exception as exc:
logger.warning("GGUF STT download failed for %s: %s", model_id, exc)
with self._lock:
self._error = f"Download failed for '{model_id}'."
_download_state = _GgmlDownloadState()
def start_model_download(model: Optional[str], hf_token: Optional[str] = None) -> None:
_download_state.start(resolve_ggml_model_id(model), hf_token)
def download_status() -> dict:
return _download_state.status()
# ---------------------------------------------------------------------------
# WAV packaging
# ---------------------------------------------------------------------------
def _pcm_to_wav_bytes(decoded_audio) -> bytes:
"""Wrap decoded float32 mono 16 kHz PCM into an in-memory 16-bit WAV."""
import numpy as np
clipped = np.clip(decoded_audio, -1.0, 1.0)
pcm16 = (clipped * 32767.0).astype("<i2")
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(_TARGET_SAMPLE_RATE)
w.writeframes(pcm16.tobytes())
return buf.getvalue()
# ---------------------------------------------------------------------------
# Sidecar
# ---------------------------------------------------------------------------
class GgmlSttSidecar:
"""Owns one whisper-server subprocess and proxies dictation to it."""
def __init__(self, keep_alive_seconds: float = STT_KEEP_ALIVE_SECONDS) -> None:
self._lock = threading.RLock()
self._process: Optional[subprocess.Popen] = None
self._port: Optional[int] = None
self._model_id: Optional[str] = None
self._idle_timer: Optional[threading.Timer] = None
self._idle_generation = 0
self._keep_alive_seconds = keep_alive_seconds
# Set while whisper-server starts so training admission can account for
# the accelerator memory it is about to bind. Read without the lock.
self._loading = False
# A still-starting whisper-server is cancellable so training can preempt
# it before it binds accelerator memory. Assigned inside self._lock but
# acted on without it: cancel_pending_load() runs while load() holds the
# lock, so the event is the source of truth and terminating the process
# is a best-effort fast path.
self._load_cancel_event: Optional[threading.Event] = None
self._starting_process: Optional[subprocess.Popen] = None
# Set before the updater waits for _lock, then kept set while it owns
# the lock and atomically replaces the managed install tree. New loads
# fail fast instead of starting a process from files being swapped.
self._update_in_progress = False
@property
def loaded_model(self) -> Optional[str]:
# Lock-free status read (like stt_sidecar.py): transcribe() holds
# self._lock for the whole inference call (up to
# _TRANSCRIBE_TIMEOUT_SECONDS), and status polls plus training admission
# must not block behind it. _process_alive() snapshots self._process
# before poll(), which subprocess guards with _waitpid_lock, so a
# concurrent unload is safe.
return self._model_id if self._process_alive() else None
@property
def device(self) -> Optional[str]:
return "whisper.cpp" if self._process_alive() else None
def is_loading(self) -> bool:
# True only while whisper-server is starting (seconds to bind its GPU
# backend); load() sets and clears the flag around that window.
return self._loading
@property
def keep_alive_seconds(self) -> float:
return self._keep_alive_seconds
def _process_alive(self) -> bool:
# Snapshot self._process once: a concurrent unload() nulls it under the
# lock, so lock-free readers would otherwise re-read None between the
# truthiness check and .poll().
process = self._process
return process is not None and process.poll() is None
# -- idle unload ------------------------------------------------------
def _cancel_idle_unload_locked(self) -> None:
self._idle_generation += 1
if self._idle_timer is not None:
self._idle_timer.cancel()
self._idle_timer = None
def _schedule_idle_unload_locked(self) -> None:
self._cancel_idle_unload_locked()
if not self._process_alive():
return
generation = self._idle_generation
timer = threading.Timer(self._keep_alive_seconds, self._idle_unload, args = (generation,))
timer.daemon = True
self._idle_timer = timer
timer.start()
def _idle_unload(self, generation: int) -> None:
with self._lock:
if generation != self._idle_generation:
return
logger.info("Unloading idle GGUF STT model %s", self._model_id)
self._release_locked()
# -- process lifecycle -------------------------------------------------
def _release_locked(self) -> None:
self._cancel_idle_unload_locked()
process = self._process
self._process = None
self._port = None
self._model_id = None
if process is not None and process.poll() is None:
process.terminate()
try:
process.wait(timeout = 10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout = 10)
if process is not None:
forget_pid(process.pid)
def unload(self) -> None:
with self._lock:
self._release_locked()
def _raise_if_update_in_progress(self) -> None:
if self._update_in_progress:
raise SttEngineUnavailableError(
"The local transcription runtime is being updated. Try dictation again shortly."
)
@contextmanager
def update_maintenance(self) -> Iterator[bool]:
"""Block new loads while the managed whisper.cpp tree is replaced.
The flag is published before waiting for an existing transcription to
release ``_lock``. Holding that lock across the yielded installer phase
prevents Windows from relocking the executable and prevents every host
from starting a process against a partially swapped tree. The yielded
value records whether a warm model had to be unloaded.
"""
self._update_in_progress = True
try:
with self._lock:
model_was_active = self._process_alive()
self._release_locked()
yield model_was_active
finally:
self._update_in_progress = False
def cancel_pending_load(self) -> bool:
# Preempt a starting whisper-server so training does not launch while it
# binds accelerator memory. load() holds self._lock for the whole startup,
# so act without the lock: signal abort and terminate the starting
# process. _wait_for_server observes the event and raises, then load()
# reaps the process and releases the lock.
if not self._loading:
return False
event = self._load_cancel_event
if event is None:
return False
event.set()
process = self._starting_process
if process is not None and process.poll() is None:
try:
process.terminate()
except Exception:
pass
return True
def wait_for_load_to_settle(self) -> None:
# load() holds self._lock across startup and cancel cleanup, so acquiring
# it blocks until a cancelled server is killed, reaped, and its
# accelerator memory released.
with self._lock:
pass
@staticmethod
def _reserve_free_port() -> tuple[socket.socket, int]:
"""Bind an ephemeral port and keep the socket held.
The caller closes the reservation immediately before spawning
whisper-server, shrinking the window in which another local process
could bind the port. SO_REUSEADDR lets the child rebind right after.
"""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 0))
return s, s.getsockname()[1]
def _ensure_model_downloaded(self, model_id: str) -> str:
path = _cached_model_path(model_id)
if path is None:
raise SttModelNotDownloadedError(
f"STT model '{model_id}' (GGUF) is not downloaded. "
"Download it in Settings, then Voice, before loading it."
)
return path
def load(self, model: Optional[str] = None) -> None:
"""Start (or switch) whisper-server for the requested curated model."""
self._raise_if_update_in_progress()
model_id = resolve_ggml_model_id(model)
with self._lock:
self._raise_if_update_in_progress()
binary = ensure_engine_available()
if self._process_alive() and self._model_id == model_id:
self._schedule_idle_unload_locked()
return
model_path = self._ensure_model_downloaded(model_id)
self._release_locked()
reservation, port = self._reserve_free_port()
command = [binary, "-m", model_path, "--host", "127.0.0.1", "--port", str(port)]
marker = _whisper_install_marker(binary)
if _training_active():
# Keep whisper.cpp off the accelerator during training (like the
# Transformers sidecar's CPU choice) so a mid-training dictation
# cannot reclaim the VRAM training just freed.
command.append("--no-gpu")
elif marker is not None and marker.get("backend") == "cpu":
# A deliberate CPU install must stay CPU: the slim wiring links
# every llama ggml backend (including CUDA/ROCm), so without
# this flag a cpu-selected install would still grab the GPU.
command.append("--no-gpu")
logger.info(
"Starting whisper-server for STT model %s on 127.0.0.1:%s",
model_id,
port,
)
cancel_event = threading.Event()
self._load_cancel_event = cancel_event
self._loading = True
try:
# Release the reservation as late as possible: whisper-server
# binds the port moments after this close.
reservation.close()
process = subprocess.Popen(
command,
stdout = subprocess.DEVNULL,
stderr = subprocess.DEVNULL,
stdin = subprocess.DEVNULL,
# Co-located GPU libs on the loader path (WSL system HIP first),
# secrets scrubbed from the downloaded binary's env.
env = _whisper_server_child_env(binary),
# Die with Studio (Linux PDEATHSIG, Windows job) so a crash
# never orphans a server holding the model.
**child_popen_kwargs(),
)
self._starting_process = process
adopt_pid(process.pid) # terminate_all backstop for graceful exits
try:
self._wait_for_server(process, port, cancel_event)
except Exception:
if process.poll() is None:
process.kill()
process.wait(timeout = 10)
forget_pid(process.pid)
raise
self._process = process
self._port = port
self._model_id = model_id
self._schedule_idle_unload_locked()
finally:
reservation.close() # no-op when already released before spawn
self._loading = False
self._load_cancel_event = None
self._starting_process = None
@staticmethod
def _wait_for_server(
process: subprocess.Popen,
port: int,
cancel_event: Optional[threading.Event] = None,
) -> None:
deadline = time.monotonic() + _SERVER_START_TIMEOUT_SECONDS
while time.monotonic() < deadline:
if cancel_event is not None and cancel_event.is_set():
raise SttLoadCancelledError(
"GGUF STT model loading was cancelled so training could start."
)
if process.poll() is not None:
raise SttEngineUnavailableError(
"The local transcription runtime exited before becoming "
"ready; the model file may be corrupt or unsupported."
)
# Require a whisper-server-specific response twice, with the managed
# child alive around each probe. An arbitrary local process that won
# the bind race would otherwise be mistaken for the sidecar and
# receive the user's microphone audio.
if GgmlSttSidecar._probe_is_whisper_server(process, port) and (
GgmlSttSidecar._probe_is_whisper_server(process, port)
):
return
time.sleep(0.2)
raise SttEngineUnavailableError("The local transcription runtime did not start in time.")
@staticmethod
def _probe_is_whisper_server(process: subprocess.Popen, port: int) -> bool:
"""One readiness probe: our child is alive and the responder looks like
whisper.cpp's server (its index page and errors identify whisper)."""
if process.poll() is not None:
return False
try:
req = urllib.request.Request(f"http://127.0.0.1:{port}/", method = "GET")
with urllib.request.urlopen(req, timeout = 2) as response:
body = response.read(65536)
except Exception:
return False
if process.poll() is not None:
return False
return b"whisper" in body.lower()
# -- transcription ------------------------------------------------------
def transcribe(
self,
audio: bytes,
model: Optional[str] = None,
language: Optional[str] = None,
fast: bool = False,
) -> dict:
"""Transcribe encoded audio bytes via whisper-server.
Accepts any container PyAV can decode (same validation and caps as the
Transformers sidecar). Returns {text, language, duration, model}.
"""
self._raise_if_update_in_progress()
ensure_engine_available()
model_id = resolve_ggml_model_id(model)
lang = normalize_whisper_language(language)
known_languages = _known_whisper_languages()
if lang is not None and known_languages is not None and lang not in known_languages:
raise SttLanguageError(
f"Language '{language}' is not supported by STT model '{model_id}'."
)
# Reject a missing model before decoding so a long clip does not burn CPU
# only to 409 (matches the Transformers sidecar's preflight).
self._ensure_model_downloaded(model_id)
decoded_audio = _decode_audio_bounded(audio)
wav_bytes = _pcm_to_wav_bytes(decoded_audio)
with self._lock:
try:
self.load(model_id)
text = self._post_inference(wav_bytes, lang, fast)
finally:
self._schedule_idle_unload_locked()
duration = (len(decoded_audio) / _TARGET_SAMPLE_RATE) if len(decoded_audio) else None
return {
"text": text,
"language": lang,
"duration": duration,
"model": model_id,
}
def _post_inference(self, wav_bytes: bytes, lang: Optional[str], fast: bool) -> str:
boundary = uuid.uuid4().hex
fields = {
"temperature": "0.0",
"response_format": "json",
# Match the Transformers sidecar: 5-way beam search, greedy for fast.
"beam_size": "1" if fast else "5",
"language": lang or "auto",
}
parts: list[bytes] = []
for name, value in fields.items():
parts.append(
(
f"--{boundary}\r\nContent-Disposition: form-data; "
f'name="{name}"\r\n\r\n{value}\r\n'
).encode()
)
parts.append(
(
f"--{boundary}\r\nContent-Disposition: form-data; "
'name="file"; filename="dictation.wav"\r\n'
"Content-Type: audio/wav\r\n\r\n"
).encode()
+ wav_bytes
+ b"\r\n"
)
parts.append(f"--{boundary}--\r\n".encode())
body = b"".join(parts)
req = urllib.request.Request(
f"http://127.0.0.1:{self._port}/inference",
data = body,
headers = {"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
try:
with urllib.request.urlopen(req, timeout = _TRANSCRIBE_TIMEOUT_SECONDS) as resp:
payload = json.load(resp)
except SttAudioDecodeError:
raise
except Exception as exc:
raise SttEngineUnavailableError(
"The local transcription runtime did not answer the request."
) from exc
text = payload.get("text")
if not isinstance(text, str):
raise SttAudioDecodeError("Could not decode the audio.")
# whisper.cpp joins segments with newlines; dictation wants one line.
return " ".join(part.strip() for part in text.splitlines() if part.strip()).strip()
_sidecar: Optional[GgmlSttSidecar] = None
def get_ggml_stt_sidecar() -> GgmlSttSidecar:
global _sidecar
if _sidecar is None:
_sidecar = GgmlSttSidecar()
return _sidecar

File diff suppressed because it is too large Load diff

View file

@ -188,7 +188,14 @@ class LlamaServerBackend:
match = [f for f in files if variant in f.lower()] or files
filename = sorted(match, key = len)[0]
logger.info("resolving GGUF embedder %s/%s", repo, filename)
self._model_path = hf_hub_download(repo_id = repo, filename = filename, token = token)
from utils.hf_cache_settings import active_hf_hub_cache
self._model_path = hf_hub_download(
repo_id = repo,
filename = filename,
token = token,
cache_dir = active_hf_hub_cache(),
)
self._model_repo = desired
self._dim = None
return self._model_path

View file

@ -104,9 +104,15 @@ def _st_module_subdirs(name: str, token: str | None) -> tuple[str, ...]:
else:
from huggingface_hub import hf_hub_download
from huggingface_hub.utils import EntryNotFoundError
from utils.hf_cache_settings import active_hf_hub_cache
try:
local = hf_hub_download(name, "modules.json", token = token or None)
local = hf_hub_download(
name,
"modules.json",
token = token or None,
cache_dir = active_hf_hub_cache(),
)
except EntryNotFoundError:
return ()
data = json.loads(open(local).read())
@ -183,11 +189,16 @@ def _get(model_name: str | None = None):
if _model is None or _name != name:
_install_torchao_stub_once()
from sentence_transformers import SentenceTransformer
from utils.hf_cache_settings import active_hf_hub_cache
device = _device()
logger.info("loading embedding model %s on %s", name, device)
_guard_model_security(name, local_only)
st_kwargs = dict(device = device, model_kwargs = dtype_kwargs("float16"))
st_kwargs = dict(
device = device,
cache_folder = active_hf_hub_cache(),
model_kwargs = dtype_kwargs("float16"),
)
load_target = name
if local_only:
from utils.utils import hf_cache_snapshot_dir

View file

@ -754,6 +754,9 @@ class TrainingBackend:
def __init__(self):
# Subprocess state
self._proc: Optional[mp.Process] = None
# True from the sidecar-swap handshake until the worker is recorded, so
# installs and STT loads treat the startup window as active.
self._spawn_in_progress: bool = False
self._event_queue: Any = None
self._stop_queue: Any = None
self._pump_thread: Optional[threading.Thread] = None
@ -929,16 +932,21 @@ class TrainingBackend:
config["resolved_gpu_ids"] = resolved_gpu_ids
config["gpu_selection"] = gpu_selection
from .worker import run_training_process
from utils.hf_cache_settings import child_environment_for_spawn, get_hf_cache_paths
cache_env = get_hf_cache_paths().child_env({})
try:
with native_path_secret_removed_for_child_start():
with (
child_environment_for_spawn(cache_env),
native_path_secret_removed_for_child_start(),
):
event_queue = _CTX.Queue()
stop_queue = _CTX.Queue()
proc = _CTX.Process(
target = run_without_native_path_secret,
args = (run_training_process,),
args = ("core.training.worker", "run_training_process", cache_env),
kwargs = {
"event_queue": event_queue,
"stop_queue": stop_queue,
@ -991,6 +999,7 @@ class TrainingBackend:
self._db_started_at = datetime.now(timezone.utc).isoformat()
# Start each job Xet-first; keep config so a stall can respawn over HTTP.
self._last_full_config = config
self._last_hf_cache_env = cache_env
self._in_model_load = False
self._xet_fallback_used = False
self._needs_xet_respawn = False
@ -1400,7 +1409,11 @@ class TrainingBackend:
self._last_full_config = config
logger.warning("Respawning training worker with HF_HUB_DISABLE_XET=1 after Xet stall")
from .worker import run_training_process
cache_env = getattr(self, "_last_hf_cache_env", None)
if not cache_env:
from utils.hf_cache_settings import get_hf_cache_paths
cache_env = get_hf_cache_paths().child_env({})
from utils.hf_cache_settings import child_environment_for_spawn
# This run is active, so an install request 409s rather than proceeds: a reservation seen here
# is transient (an aborting install or short lazy repair). Wait it out instead of stranding the
@ -1432,12 +1445,15 @@ class TrainingBackend:
# crashed respawn cannot wedge is_training_active until restart.
try:
try:
with native_path_secret_removed_for_child_start():
with (
child_environment_for_spawn(cache_env),
native_path_secret_removed_for_child_start(),
):
event_queue = _CTX.Queue()
stop_queue = _CTX.Queue()
new_proc = _CTX.Process(
target = run_without_native_path_secret,
args = (run_training_process,),
args = ("core.training.worker", "run_training_process", cache_env),
kwargs = {
"event_queue": event_queue,
"stop_queue": stop_queue,

View file

@ -61,9 +61,11 @@ async def list_cached_datasets(current_subject: str = Depends(get_current_subjec
@router.delete("/cached", response_model = DeleteCachedDatasetResponse)
async def delete_cached_dataset(
repo_id: str = Body(..., embed = True), current_subject: str = Depends(get_current_subject)
repo_id: str = Body(..., embed = True),
cache_path: Optional[str] = Body(None, embed = True),
current_subject: str = Depends(get_current_subject),
):
return await cache_inventory.delete_cached_dataset_response(repo_id)
return await cache_inventory.delete_cached_dataset_response(repo_id, cache_path)
@router.get("/download-progress", response_model = DownloadProgressResponse)

View file

@ -233,7 +233,8 @@ async def list_hidden_models(current_subject: str = Depends(get_current_subject)
async def delete_cached_model(
repo_id: str = Body(...),
variant: Optional[str] = Body(None),
cache_path: Optional[str] = Body(None),
hf_token: Optional[str] = Depends(get_hf_token),
current_subject: str = Depends(get_current_subject),
):
return await deletion.delete_cached_model_response(repo_id, variant, hf_token)
return await deletion.delete_cached_model_response(repo_id, variant, hf_token, cache_path)

View file

@ -99,6 +99,10 @@ class LocalModelInfo(BaseModel):
None,
description = "HF repo id for cached models, e.g. org/model",
)
active_cache: Optional[bool] = Field(
None,
description = "Whether this HF entry belongs to the current download cache.",
)
base_model: Optional[str] = Field(
None,
description = "Base model from adapter_config.json when this is an adapter",

View file

@ -20,12 +20,11 @@ from hub.utils import inventory_scan as hf_cache_scan
from hub.utils.hf_cache_state import (
purge_partial_repo,
purge_repo_cache_dirs,
resolve_delete_target_root,
resolve_destructive_case_matches,
)
from hub.utils.paths import (
hf_default_cache_dir,
is_valid_repo_id as _is_valid_repo_id,
legacy_hf_cache_dir,
resolve_cached_repo_id_case,
)
@ -43,38 +42,8 @@ def _collect_hf_cache_scans() -> tuple[list, set[str]]:
def _hf_hub_cache_roots() -> list[Path]:
roots: list[Path] = []
seen: set[str] = set()
def _add(path: Optional[Path]) -> None:
if path is None or not path.is_dir():
return
try:
resolved = str(path.resolve())
except OSError:
return
if resolved in seen:
return
seen.add(resolved)
roots.append(path)
try:
from huggingface_hub.constants import HF_HUB_CACHE
_add(Path(HF_HUB_CACHE))
except Exception:
pass
hf_hub_cache = os.environ.get("HF_HUB_CACHE")
if hf_hub_cache:
_add(Path(hf_hub_cache).expanduser())
hf_home = os.environ.get("HF_HOME")
if hf_home:
_add(Path(hf_home).expanduser() / "hub")
_add(legacy_hf_cache_dir())
_add(hf_default_cache_dir())
return roots
from hub.utils.hf_cache_state import hf_cache_roots
return hf_cache_roots()
def _repo_id_from_hub_dataset_dir(name: str) -> str | None:
@ -207,6 +176,21 @@ def _repo_id_from_datasets_cache_dir(name: str) -> str | None:
return repo_id if _is_valid_repo_id(repo_id) else None
def _is_processed_dataset_cache_path(repo_id: str, cache_path: str) -> bool:
"""True when *cache_path* is this repo's processed Arrow cache dir
(``<owner>___<repo>`` directly under an HF_DATASETS_CACHE root). Such rows
have no Hub ``datasets--`` layout, so they are deleted via the processed
path and must not be rejected as an invalid cache_path."""
try:
resolved = Path(cache_path).expanduser().resolve(strict = False)
except (OSError, RuntimeError, ValueError):
return False
if resolved.name.lower() != repo_id.replace("/", "___").lower():
return False
roots = {r.resolve(strict = False) for r in _hf_datasets_cache_roots()}
return resolved.parent.resolve(strict = False) in roots
def _processed_dataset_cache_size(path: Path) -> int:
total = 0
try:
@ -361,7 +345,7 @@ async def list_cached_datasets_response() -> dict:
) from exc
async def delete_cached_dataset_response(repo_id: str) -> dict:
async def delete_cached_dataset_response(repo_id: str, cache_path: Optional[str] = None) -> dict:
"""Remove a cached dataset repo from the HF cache."""
if not _is_valid_repo_id(repo_id):
raise HTTPException(status_code = 400, detail = "Invalid repo_id format")
@ -373,22 +357,40 @@ async def delete_cached_dataset_response(repo_id: str) -> dict:
detail = "Cancel the active download before deleting.",
)
try:
return await asyncio.to_thread(_delete_cached_dataset_blocking, repo_key)
return await asyncio.to_thread(_delete_cached_dataset_blocking, repo_key, cache_path)
finally:
downloads.registry.end_delete(repo_key)
hf_cache_scan.invalidate_hf_cache_scans()
def _delete_cached_dataset_blocking(repo_id: str) -> dict:
def _delete_cached_dataset_blocking(repo_id: str, cache_path: Optional[str] = None) -> dict:
scans, _seen_roots = _collect_hf_cache_scans()
candidate_entries = []
# Group this dataset's copies by owning cache root, then target exactly one
# cache so a delete never removes copies in other, previously selected caches.
owners: dict = {}
for hf_cache in scans:
for repo_info in hf_cache.repos:
if str(repo_info.repo_type) != "dataset":
continue
if repo_info.repo_id.lower() == repo_id.lower():
candidate_entries.append((hf_cache, repo_info))
if repo_info.repo_id.lower() != repo_id.lower():
continue
try:
owner = Path(repo_info.repo_path).parent.resolve(strict = False)
except (OSError, RuntimeError, ValueError):
continue
owners.setdefault(owner, []).append((hf_cache, repo_info))
target_root = resolve_delete_target_root("dataset", repo_id, cache_path, owners.keys())
# A processed-only dataset row sends its Arrow cache path (<owner>___<repo>
# under HF_DATASETS_CACHE), which is not a Hub datasets-- dir, so
# resolve_delete_target_root returns None. Accept it and fall through to the
# processed-cache delete rather than rejecting a legitimate row.
if target_root is None and not (
cache_path and _is_processed_dataset_cache_path(repo_id, cache_path)
):
raise HTTPException(status_code = 400, detail = "Invalid cache_path")
candidate_entries = owners.get(target_root, []) if target_root is not None else []
matched_repo_ids = resolve_destructive_repo_ids(
repo_id,
[str(repo_info.repo_id) for _hf_cache, repo_info in candidate_entries],
@ -414,7 +416,26 @@ def _delete_cached_dataset_blocking(repo_id: str) -> dict:
exc_info = True,
)
processed_deleted, processed_failures = _delete_processed_dataset_cache(repo_id)
# Restrict the processed Arrow-cache delete to the selected cache's datasets
# root so it never removes copies under other cache homes. A processed
# cache_path scopes to its own root; a Hub target scopes to the datasets root
# sharing its cache home; an unspecified cache_path stays global (legacy).
processed_roots: Optional[set[Path]]
if not cache_path:
processed_roots = None
elif _is_processed_dataset_cache_path(repo_id, cache_path):
processed_roots = {Path(cache_path).expanduser().resolve(strict = False).parent}
else:
home = target_root.parent if target_root is not None else None
processed_roots = {
root.resolve(strict = False)
for root in _hf_datasets_cache_roots()
if home is not None and root.resolve(strict = False).parent == home
}
processed_deleted, processed_failures = _delete_processed_dataset_cache(
repo_id, only_roots = processed_roots
)
failures.extend(processed_failures)
if failures:
raise HTTPException(
@ -427,15 +448,23 @@ def _delete_cached_dataset_blocking(repo_id: str) -> dict:
# ``scan_cache_dir()`` skips blob-only/corrupt repos the revision delete
# can't touch, yet the fallback scanner shows them; purge the whole dir.
cache_purged = purge_repo_cache_dirs("dataset", repo_id)
partial_purged = purge_partial_repo("dataset", repo_id)
state_purged = download_manifest.purge_all_state_for_repo("dataset", repo_id) > 0
# Only for a Hub cache target; a processed-only path has no Hub dir/state.
cache_purged = partial_purged = state_purged = False
if target_root is not None:
cache_purged = purge_repo_cache_dirs("dataset", repo_id, root = target_root)
partial_purged = purge_partial_repo("dataset", repo_id, root = target_root)
state_purged = (
download_manifest.purge_all_state_for_repo("dataset", repo_id, hub_cache = target_root)
> 0
)
if not (deleted or processed_deleted or cache_purged or partial_purged or state_purged):
raise HTTPException(status_code = 404, detail = "Dataset not found in cache")
return {"status": "deleted", "repo_id": repo_id}
def _delete_processed_dataset_cache(repo_id: str) -> tuple[bool, list[str]]:
def _delete_processed_dataset_cache(
repo_id: str, only_roots: Optional[set[Path]] = None
) -> tuple[bool, list[str]]:
import shutil
target = repo_id.replace("/", "___")
@ -443,6 +472,10 @@ def _delete_processed_dataset_cache(repo_id: str) -> tuple[bool, list[str]]:
deleted = False
failures: list[str] = []
for root in _hf_datasets_cache_roots():
# Scope to the selected cache's datasets root(s): a delete must not remove
# processed copies living under other, previously selected cache homes.
if only_roots is not None and root.resolve(strict = False) not in only_roots:
continue
try:
entries = [
entry

View file

@ -159,12 +159,18 @@ async def download_dataset_response(
use_xet = download_lifecycle.resolve_effective_use_xet(body.use_xet)
transport = download_lifecycle.resolve_transport(use_xet)
from utils.hf_cache_settings import get_hf_cache_paths
cache_paths = get_hf_cache_paths()
cache_env = cache_paths.child_env({})
claimed, claim_state = _registry.claim(
key,
transport,
repo_type = "dataset",
repo_id = repo_id,
hub_cache = str(cache_paths.hub_cache),
xet_cache = str(cache_paths.xet_cache),
)
generation = _registry.current_generation(key)
if not claimed:
@ -176,7 +182,12 @@ async def download_dataset_response(
"accepted": _registry.adoptable(key),
"generation": generation,
}
download_manifest.clear_cancel_marker("dataset", repo_id, None)
download_manifest.clear_cancel_marker(
"dataset",
repo_id,
None,
hub_cache = cache_paths.hub_cache,
)
state = download_lifecycle.launch_worker(
_registry,
@ -185,6 +196,7 @@ async def download_dataset_response(
["--repo-id", repo_id, "--dataset"],
hf_token,
use_xet = use_xet,
cache_env = cache_env,
),
hf_token = hf_token,
label = repo_id,

View file

@ -11,7 +11,7 @@ import sys
import time
import threading
from pathlib import Path
from typing import Callable, Optional
from typing import Callable, Mapping, Optional
from fastapi import HTTPException
@ -57,6 +57,7 @@ def spawn_worker(
*,
use_xet: bool,
protected_blob_hashes: Optional[frozenset[str]] = None,
cache_env: Optional[Mapping[str, str]] = None,
) -> subprocess.Popen:
"""Spawn the download worker.
@ -68,7 +69,11 @@ def spawn_worker(
"""
cwd = backend_dir()
mode = download_registry.TRANSPORT_XET if use_xet else download_registry.TRANSPORT_HTTP
env = os.environ.copy()
from utils.hf_cache_settings import get_hf_cache_paths
env = get_hf_cache_paths().child_env()
if cache_env is not None:
env.update(cache_env)
if protected_blob_hashes:
env["UNSLOTH_PROTECTED_BLOB_HASHES"] = ",".join(sorted(protected_blob_hashes))
else:
@ -230,6 +235,7 @@ def finalize_worker_exit(
(stderr_data or b"").decode("utf-8", "replace").strip(),
hf_token = hf_token,
)
metadata = registry.get_job_metadata(key)
state = classify_exit(rc, cancel_requested = cancel_requested)
if state == "complete":
registry.set_job(key, "complete")
@ -252,13 +258,13 @@ def finalize_worker_exit(
repo_type,
repo_id,
download_registry.variant_from_key(key),
hub_cache = metadata.hub_cache if metadata is not None else None,
)
except Exception as exc:
logger.debug(f"clear_cancel_marker failed for {repo_id} (rc=0): {exc}")
elif state == "cancelled":
# Read metadata before the terminal set_job so a concurrent eviction
# can't drop it; the job key is the fallback variant label.
metadata = registry.get_job_metadata(key)
registry.set_job(key, "cancelled")
logger.info(f"{log_prefix} cancelled: {label} (rc={rc})")
download_registry.persist_cancel_marker(
@ -268,6 +274,7 @@ def finalize_worker_exit(
if metadata is not None and metadata.variant
else download_registry.variant_from_key(key),
cancel_marker_transport or transport,
hub_cache = metadata.hub_cache if metadata is not None else None,
logger = logger,
)
else:
@ -303,6 +310,7 @@ def _set_retry_failure_state(
metadata.transport
if metadata is not None and metadata.transport
else fallback_transport,
hub_cache = metadata.hub_cache if metadata is not None else None,
logger = logger,
)
return state
@ -371,6 +379,7 @@ def _try_http_retry(
repo_type,
repo_id,
progress_blob_hashes,
root = Path(original_metadata.hub_cache) if original_metadata.hub_cache else None,
)
if progress_blob_hashes
else 0
@ -403,6 +412,8 @@ def _try_http_retry(
generation = generation,
replace_active = True,
cancel_marker_transport = original_metadata.transport,
hub_cache = original_metadata.hub_cache,
xet_cache = original_metadata.xet_cache,
)
if claimed:
break
@ -446,11 +457,24 @@ def _try_http_retry(
label,
)
try:
cache_env = (
{
"HF_HUB_CACHE": original_metadata.hub_cache,
"HF_XET_CACHE": original_metadata.xet_cache,
}
if original_metadata.hub_cache and original_metadata.xet_cache
else None
)
spawn_kwargs = {
"use_xet": False,
"protected_blob_hashes": peer_hashes or None,
}
if cache_env is not None:
spawn_kwargs["cache_env"] = cache_env
proc = spawn_worker(
args,
hf_token,
use_xet = False,
protected_blob_hashes = peer_hashes or None,
**spawn_kwargs,
)
except Exception as exc:
scrubbed = download_registry.scrub_secrets(str(exc), hf_token = hf_token)

View file

@ -34,7 +34,6 @@ from hub.services.models.common import (
_is_mmproj_filename,
_is_transformers_safetensors_weight_name,
_local_inventory_id,
_prefer_complete_larger,
_runtime_for_format,
)
@ -250,24 +249,46 @@ def _repo_gguf_blob_map(repo_info, *, include_companions: bool = False) -> dict[
def _prefer_cache_row(candidate: dict, existing: Optional[dict]) -> bool:
if existing is None:
return True
return _prefer_complete_larger(
bool(candidate.get("partial")),
int(candidate.get("size_bytes") or 0),
bool(existing.get("partial")),
int(existing.get("size_bytes") or 0),
)
candidate_partial = bool(candidate.get("partial"))
existing_partial = bool(existing.get("partial"))
if candidate_partial != existing_partial:
return not candidate_partial
candidate_active = bool(candidate.get("active_cache"))
existing_active = bool(existing.get("active_cache"))
if candidate_active != existing_active:
return candidate_active
return int(candidate.get("size_bytes") or 0) > int(existing.get("size_bytes") or 0)
def _cache_inventory_fields(
repo_id: str,
model_format: ModelFormat,
*,
repo_path: Optional[Path] = None,
snapshot_path: Optional[Path] = None,
active_hub_cache: Optional[Path] = None,
partial: bool = False,
requires_variant: bool = False,
) -> dict:
load_id = repo_id
active_cache = True
if repo_path is not None:
try:
if active_hub_cache is None:
from utils.hf_cache_settings import get_hf_cache_paths
active_hub_cache = get_hf_cache_paths().hub_cache
active_root = active_hub_cache.resolve(strict = False)
cached_root = repo_path.parent.resolve(strict = False)
if cached_root != active_root:
active_cache = False
load_id = str(snapshot_path or repo_path.resolve(strict = False))
except (OSError, RuntimeError, ValueError):
active_cache = False
load_id = str(snapshot_path or repo_path)
return {
"inventory_id": _local_inventory_id("cache", model_format, repo_id),
"load_id": repo_id,
"load_id": load_id,
"active_cache": active_cache,
"model_format": model_format,
"runtime": _runtime_for_format(model_format),
"format_variant": None,
@ -294,6 +315,9 @@ def _is_hidden_infra_repo(*values: str | None) -> bool:
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()
from utils.hf_cache_settings import get_hf_cache_paths
active_hub_cache = get_hf_cache_paths().hub_cache
seen_lower: dict[str, dict] = {}
for hf_cache in cache_scans:
@ -305,7 +329,10 @@ def _scan_cached_gguf() -> list[dict]:
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)
has_variant_state, variant_state_size = _gguf_variant_state_summary(
repo_id,
hub_cache = repo_path.parent,
)
is_hidden_infra = _is_hidden_infra_repo(
repo_id,
str(repo_path),
@ -342,6 +369,9 @@ def _scan_cached_gguf() -> list[dict]:
_cache_inventory_fields(
repo_id,
"gguf",
repo_path = repo_path,
snapshot_path = snapshot_path,
active_hub_cache = active_hub_cache,
partial = bool(row["partial"]),
requires_variant = True,
)
@ -487,6 +517,19 @@ def _read_json_object(path: Path) -> dict:
return {}
def _is_whisper_model_config(config: object) -> bool:
if not isinstance(config, dict):
return False
model_type = config.get("model_type")
if isinstance(model_type, str) and model_type.strip().lower() == "whisper":
return True
architectures = config.get("architectures")
return isinstance(architectures, list) and any(
isinstance(name, str) and name == "WhisperForConditionalGeneration"
for name in architectures
)
def _read_model_card_frontmatter(path: Path) -> dict:
try:
text = path.read_text(encoding = "utf-8")
@ -517,6 +560,8 @@ def _cached_model_local_metadata(repo_path: Path) -> dict:
result: dict = {}
config = _read_json_object(snapshot / "config.json")
if _is_whisper_model_config(config):
result["_hidden_stt"] = True
quant_method = (
config.get("quantization_config", {}).get("quant_method")
if isinstance(config.get("quantization_config"), dict)
@ -543,11 +588,15 @@ def _cached_model_local_metadata(repo_path: Path) -> dict:
def _scan_cached_models() -> list[dict]:
"""Synchronous HF-cache disk walk for non-GGUF model repos; runs in a worker thread."""
cache_scans = all_hf_cache_scans()
from utils.hf_cache_settings import get_hf_cache_paths
active_hub_cache = get_hf_cache_paths().hub_cache
seen_lower: dict[str, dict] = {}
inspected = 0
skipped_gguf = 0
skipped_no_weights = 0
skipped_stt = 0
for hf_cache in cache_scans:
for repo_info in hf_cache.repos:
inspected += 1
@ -575,6 +624,10 @@ def _scan_cached_models() -> list[dict]:
continue
key = repo_id.lower()
existing = seen_lower.get(key)
local_metadata = _cached_model_local_metadata(repo_path)
if local_metadata.pop("_hidden_stt", False):
skipped_stt += 1
continue
snapshot_partial = hf_cache_scan.is_snapshot_partial(
"model",
repo_id,
@ -594,7 +647,7 @@ def _scan_cached_models() -> list[dict]:
if snapshot_partial
else None
),
**_cached_model_local_metadata(repo_path),
**local_metadata,
}
last_modified = max(
payload.last_modified,
@ -606,6 +659,9 @@ def _scan_cached_models() -> list[dict]:
_cache_inventory_fields(
repo_id,
payload.model_format,
repo_path = repo_path,
snapshot_path = snapshot_path,
active_hub_cache = active_hub_cache,
partial = bool(row["partial"]),
)
)
@ -619,10 +675,12 @@ def _scan_cached_models() -> list[dict]:
continue
cached = sorted(seen_lower.values(), key = lambda c: c["repo_id"])
logger.info(
"Cached model scan: inspected=%d skipped_gguf=%d skipped_no_weights=%d returned=%d",
"Cached model scan: inspected=%d skipped_gguf=%d skipped_no_weights=%d "
"skipped_stt=%d returned=%d",
inspected,
skipped_gguf,
skipped_no_weights,
skipped_stt,
len(cached),
)
return cached

View file

@ -150,7 +150,9 @@ def _prefer_complete_larger(
return candidate_size_bytes > existing_size_bytes
def _gguf_variant_state_summary(repo_id: str) -> tuple[bool, int]:
def _gguf_variant_state_summary(
repo_id: str, *, hub_cache: Optional[str | Path] = None
) -> tuple[bool, int]:
"""Whether GGUF variant-scoped state exists and its expected size; a cancelled/in-progress variant may have only manifests/markers/`.incomplete` blobs, which inventory needs to avoid a generic fallback row."""
from hub.utils import download_manifest
@ -159,10 +161,16 @@ def _gguf_variant_state_summary(repo_id: str) -> tuple[bool, int]:
for variant, _path in download_manifest.iter_variant_manifests(
"model",
repo_id,
hub_cache = hub_cache,
):
key = variant.lower()
variant_keys.add(key)
manifest = download_manifest.read_manifest("model", repo_id, variant)
manifest = download_manifest.read_manifest(
"model",
repo_id,
variant,
hub_cache = hub_cache,
)
if manifest is None:
continue
size_by_variant[key] = max(
@ -172,6 +180,7 @@ def _gguf_variant_state_summary(repo_id: str) -> tuple[bool, int]:
for variant, _path in download_manifest.iter_variant_markers(
"model",
repo_id,
hub_cache = hub_cache,
):
variant_keys.add(variant.lower())
return bool(variant_keys), sum(size_by_variant.values())
@ -432,8 +441,13 @@ def _local_model_info(
base_model_source: Optional[str] = None,
adapter_type: Optional[str] = None,
training_method: Optional[str] = None,
active_cache: Optional[bool] = None,
) -> LocalModelInfo:
load_id = model_id if source == "hf_cache" and model_id else str(load_path)
load_id = (
model_id
if source == "hf_cache" and model_id and active_cache is not False
else str(load_path)
)
semantic_id = model_id or str(load_path)
return LocalModelInfo(
id = load_id,
@ -445,6 +459,7 @@ def _local_model_info(
),
load_id = load_id,
model_id = model_id,
active_cache = active_cache if source == "hf_cache" else None,
display_name = display_name or (scan_path.stem if scan_path.is_file() else scan_path.name),
path = str(load_path),
size_bytes = max(0, int(size_bytes or 0)),
@ -476,6 +491,7 @@ def _classify_local_path(
model_id: Optional[str] = None,
updated_at: Optional[float] = None,
partial: bool = False,
active_cache: Optional[bool] = None,
) -> list[LocalModelInfo]:
load_path = load_path or scan_path
files = (
@ -512,6 +528,7 @@ def _classify_local_path(
requires_variant = scan_path.is_dir(),
format_variant = variant,
size_bytes = gguf_size_bytes,
active_cache = active_cache,
)
)
@ -574,6 +591,7 @@ def _classify_local_path(
),
adapter_type = adapter_type if model_format == "adapter" else None,
training_method = training_method if model_format == "adapter" else None,
active_cache = active_cache,
)
)
elif not rows:
@ -592,6 +610,7 @@ def _classify_local_path(
updated_at = updated_at,
partial = partial or trusted_hf_cache_repo,
size_bytes = size_bytes,
active_cache = active_cache,
)
)

View file

@ -19,8 +19,10 @@ from hub.utils import inventory_scan as hf_cache_scan
from hub.utils.gguf import extract_quant_label, extract_quant_token
from hub.utils.hf_cache_state import (
INCOMPLETE_SUFFIX,
iter_repo_cache_dirs,
purge_partial_repo,
purge_repo_cache_dirs,
resolve_delete_target_root,
)
from hub.utils.paths import (
is_valid_gguf_variant as _is_valid_gguf_variant,
@ -184,6 +186,7 @@ def _delete_gguf_variant_from_repos(
hf_token: Optional[str],
*,
sibling_active: bool = False,
root: Optional[Path] = None,
) -> dict:
failures: list[str] = []
removed_snapshots = 0
@ -265,6 +268,7 @@ def _delete_gguf_variant_from_repos(
hf_token,
extra_hashes = frozenset(completed_hashes),
companions = not sibling_active,
root = root,
)
if incomplete_result.unresolved:
raise HTTPException(
@ -276,7 +280,7 @@ def _delete_gguf_variant_from_repos(
),
)
state_purged = download_manifest.purge_state("model", repo_id, variant)
state_purged = download_manifest.purge_state("model", repo_id, variant, hub_cache = root)
# Reclaim the empty quant folder so it stops 404ing on delete.
removed_dirs, dir_failures = _remove_empty_variant_dirs(target_repos, variant)
removed_snap_dirs, snap_dir_failures = _remove_empty_snapshot_dirs(target_repos)
@ -316,6 +320,8 @@ def reclaim_replaced_gguf_variant(
variant: str,
keep_main_hashes: frozenset[str],
hf_token: Optional[str] = None,
*,
hub_cache: Optional[str | Path] = None,
) -> dict:
"""Prune stale main-GGUF files for a variant after a replacement verified.
@ -366,12 +372,22 @@ def reclaim_replaced_gguf_variant(
"reason": "scan_failed",
}
if hub_cache is None:
from utils.hf_cache_settings import get_hf_cache_paths
hub_cache = get_hf_cache_paths().hub_cache
try:
target_hub_cache = Path(hub_cache).expanduser().resolve(strict = False)
except (OSError, RuntimeError, ValueError):
target_hub_cache = Path(hub_cache).expanduser()
candidate_repos = [
repo_info
for hf_cache in cache_scans
for repo_info in hf_cache.repos
if str(getattr(repo_info, "repo_type", "")) == "model"
and str(getattr(repo_info, "repo_id", "")).lower() == repo_id.lower()
and getattr(repo_info, "repo_path", None)
and Path(repo_info.repo_path).parent.resolve(strict = False) == target_hub_cache
]
try:
matched_repo_ids = resolve_destructive_repo_ids(
@ -493,10 +509,24 @@ def reclaim_replaced_gguf_variant(
def _loaded_id_matches_repo(loaded_id: str, repo_id: str) -> bool:
"""True when *loaded_id* is *repo_id* or a file within it; ``/``-boundary aware so ``org/model`` doesn't match sibling ``org/model-v2``."""
"""Match a loaded repo ID or an on-disk path inside any copy of the repo."""
rid = repo_id.lower()
lid = loaded_id.lower()
return lid == rid or lid.startswith(f"{rid}/")
if lid == rid or lid.startswith(f"{rid}/"):
return True
try:
loaded_path = Path(loaded_id).expanduser().resolve(strict = False)
except (OSError, RuntimeError, ValueError):
return False
for repo_dir in iter_repo_cache_dirs("model", repo_id):
try:
resolved_repo = repo_dir.resolve(strict = False)
if loaded_path == resolved_repo or loaded_path.is_relative_to(resolved_repo):
return True
except (OSError, RuntimeError, ValueError):
continue
return False
def _loaded_repo_variant_blocks_delete(
@ -560,6 +590,7 @@ async def delete_cached_model_response(
repo_id: str,
variant: Optional[str] = None,
hf_token: Optional[str] = None,
cache_path: Optional[str] = None,
):
"""Delete a cached model repo (or a specific GGUF variant) from the HF cache.
@ -603,14 +634,19 @@ async def delete_cached_model_response(
)
raise HTTPException(status_code = 400, detail = detail)
try:
return await asyncio.to_thread(_delete_cached_model_blocking, repo_id, variant, hf_token)
return await asyncio.to_thread(
_delete_cached_model_blocking, repo_id, variant, hf_token, cache_path
)
finally:
downloads.registry.end_delete(repo_key, variant)
cache_inventory.invalidate_hf_cache_scans()
def _delete_cached_model_blocking(
repo_id: str, variant: Optional[str], hf_token: Optional[str]
repo_id: str,
variant: Optional[str],
hf_token: Optional[str],
cache_path: Optional[str] = None,
) -> dict:
try:
# If a sibling quant is downloading concurrently, restrict this delete to
@ -621,13 +657,26 @@ def _delete_cached_model_blocking(
cache_scans = cache_inventory.all_hf_cache_scans()
candidate_entries = []
# A repo can live in several remembered caches. Group its copies by the
# cache root that owns each, then target exactly one cache so a delete
# never removes copies in other, previously selected caches.
owners: dict = {}
for hf_cache in cache_scans:
for repo_info in hf_cache.repos:
if str(repo_info.repo_type) != "model":
continue
if repo_info.repo_id.lower() == repo_id.lower():
candidate_entries.append((hf_cache, repo_info))
if repo_info.repo_id.lower() != repo_id.lower():
continue
try:
owner = Path(repo_info.repo_path).parent.resolve(strict = False)
except (OSError, RuntimeError, ValueError):
continue
owners.setdefault(owner, []).append((hf_cache, repo_info))
target_root = resolve_delete_target_root("model", repo_id, cache_path, owners.keys())
if target_root is None:
raise HTTPException(status_code = 400, detail = "Invalid cache_path")
candidate_entries = owners.get(target_root, [])
matched_repo_ids = resolve_destructive_repo_ids(
repo_id,
@ -642,10 +691,15 @@ def _delete_cached_model_blocking(
if not target_entries:
if variant is None:
cache_purged = purge_repo_cache_dirs("model", repo_id) or purge_partial_repo(
"model", repo_id
cache_purged = purge_repo_cache_dirs(
"model", repo_id, root = target_root
) or purge_partial_repo("model", repo_id, root = target_root)
state_purged = (
download_manifest.purge_all_state_for_repo(
"model", repo_id, hub_cache = target_root
)
> 0
)
state_purged = download_manifest.purge_all_state_for_repo("model", repo_id) > 0
if cache_purged or state_purged:
return {"status": "deleted", "repo_id": repo_id}
if variant:
@ -654,6 +708,7 @@ def _delete_cached_model_blocking(
variant,
hf_token,
companions = not sibling_active,
root = target_root,
)
if incomplete_result.unresolved:
raise HTTPException(
@ -668,6 +723,7 @@ def _delete_cached_model_blocking(
"model",
repo_id,
variant,
hub_cache = target_root,
)
if incomplete_result.deleted > 0 or state_purged:
return {
@ -684,6 +740,7 @@ def _delete_cached_model_blocking(
[repo for _cache, repo in target_entries],
hf_token,
sibling_active = sibling_active,
root = target_root,
)
deleted_revisions = False
@ -702,9 +759,11 @@ def _delete_cached_model_blocking(
delete_strategy.execute()
deleted_revisions = True
cache_purged = purge_repo_cache_dirs("model", repo_id)
partial_purged = purge_partial_repo("model", repo_id)
state_purged = download_manifest.purge_all_state_for_repo("model", repo_id) > 0
cache_purged = purge_repo_cache_dirs("model", repo_id, root = target_root)
partial_purged = purge_partial_repo("model", repo_id, root = target_root)
state_purged = (
download_manifest.purge_all_state_for_repo("model", repo_id, hub_cache = target_root) > 0
)
if not (deleted_revisions or cache_purged or partial_purged or state_purged):
raise HTTPException(status_code = 404, detail = "No revisions found for model")

View file

@ -90,6 +90,7 @@ def _spawn_download_worker(
hf_token: Optional[str],
use_xet: bool = True,
protected_blob_hashes: Optional[frozenset[str]] = None,
cache_env: Optional[dict[str, str]] = None,
) -> subprocess.Popen:
args = ["--repo-id", repo_id]
if variant:
@ -99,6 +100,7 @@ def _spawn_download_worker(
hf_token,
use_xet = use_xet,
protected_blob_hashes = protected_blob_hashes,
cache_env = cache_env,
)
@ -125,6 +127,10 @@ async def download_model_response(body: DownloadModelRequest, hf_token: Optional
key = _download_job_key(repo_id, variant)
use_xet = download_lifecycle.resolve_effective_use_xet(body.use_xet)
transport = download_lifecycle.resolve_transport(use_xet)
from utils.hf_cache_settings import get_hf_cache_paths
cache_paths = get_hf_cache_paths()
cache_env = cache_paths.child_env({})
variant_blob_hashes = frozenset()
variant_progress_blob_hashes = frozenset()
completed_baseline_bytes = 0
@ -175,6 +181,8 @@ async def download_model_response(body: DownloadModelRequest, hf_token: Optional
progress_blob_hashes = variant_progress_blob_hashes,
completed_baseline_bytes = completed_baseline_bytes,
admission_check = lambda: not _load_in_flight(repo_id),
hub_cache = str(cache_paths.hub_cache),
xet_cache = str(cache_paths.xet_cache),
)
generation = _registry.current_generation(key)
if not claimed:
@ -189,7 +197,12 @@ async def download_model_response(body: DownloadModelRequest, hf_token: Optional
"accepted": _registry.adoptable(key),
"generation": generation,
}
download_manifest.clear_cancel_marker("model", repo_id, variant)
download_manifest.clear_cancel_marker(
"model",
repo_id,
variant,
hub_cache = cache_paths.hub_cache,
)
# Blobs a concurrent same-repo variant is already writing (e.g. a shared
# mmproj). The worker must not purge these during cache preparation.
protected_blob_hashes = _registry.peer_blob_hashes(key) if variant else frozenset()
@ -204,6 +217,7 @@ async def download_model_response(body: DownloadModelRequest, hf_token: Optional
hf_token,
use_xet = use_xet,
protected_blob_hashes = protected_blob_hashes,
cache_env = cache_env,
),
hf_token = hf_token,
label = label,

View file

@ -30,6 +30,7 @@ from hub.utils.paths import (
)
from utils.paths.external_media import (
linux_run_media_mount_roots,
macos_volume_roots,
windows_drive_roots,
)
from hub.services.models.common import _safe_is_dir
@ -187,7 +188,7 @@ def _build_browse_allowlist(
_add(Path.home())
if media_roots is None:
media_roots = linux_run_media_mount_roots()
media_roots = [*linux_run_media_mount_roots(), *macos_volume_roots()]
if drive_roots is None:
drive_roots = windows_drive_roots()
for p in media_roots:
@ -195,6 +196,12 @@ def _build_browse_allowlist(
for p in drive_roots:
_add(p)
_add(_resolve_hf_cache_dir())
try:
from utils.hf_cache_settings import known_hf_cache_homes
for cache_home in known_hf_cache_homes():
_add(cache_home)
except Exception: # noqa: BLE001 -- best-effort
pass
try:
_add(hf_default_cache_dir())
except Exception: # noqa: BLE001 -- best-effort
@ -431,7 +438,7 @@ def browse_folders_response(
# Probe removable-media and Windows drive roots once; the allowlist and
# chips reuse the result so a disconnected mapped drive isn't scanned twice.
media_roots = linux_run_media_mount_roots()
media_roots = [*linux_run_media_mount_roots(), *macos_volume_roots()]
drive_roots = windows_drive_roots()
# Build the allowlist once -- the sandbox check and suggestion chips share
# it so chips are always navigable.

View file

@ -9,6 +9,7 @@ import asyncio
import threading
import time
from collections import OrderedDict
from pathlib import Path
from typing import NamedTuple, Optional
from fastapi import HTTPException
@ -22,6 +23,7 @@ from hub.utils.hf_errors import hf_error_status
from hub.utils.hf_cache_state import (
INCOMPLETE_SUFFIX,
iter_destructive_repo_cache_dirs,
repo_cache_dir_name,
)
from hub.utils.gguf import (
extract_quant_label,
@ -233,8 +235,14 @@ def _manifest_variant_blob_hashes(
variant: str,
*,
include_companions: bool = True,
repo_cache_dir: Optional[Path] = None,
) -> frozenset[str]:
manifest = download_manifest.read_manifest("model", repo_id, variant)
manifest = download_manifest.read_manifest(
"model",
repo_id,
variant,
hub_cache = repo_cache_dir.parent if repo_cache_dir is not None else None,
)
if manifest is None:
return frozenset()
variant_key = variant.lower()
@ -257,6 +265,7 @@ def gguf_variant_blob_hashes(
*,
include_companions: bool = True,
allow_remote: bool = True,
repo_cache_dir: Optional[Path] = None,
) -> frozenset[str]:
key = _variant_blob_hash_cache_key(
repo_id,
@ -271,9 +280,9 @@ def gguf_variant_blob_hashes(
repo_id,
variant,
include_companions = include_companions,
repo_cache_dir = repo_cache_dir,
)
if hashes:
_variant_hash_cache_set(key, hashes)
return hashes
requirement_key = _variant_hash_cache_key(repo_id, variant, hf_token)
requirement = _variant_requirement_cache_get(requirement_key)
@ -287,11 +296,22 @@ def gguf_variant_blob_hashes(
return frozenset()
def _partial_transport_for_variant(repo_id: str, variant: str) -> Optional[str]:
return hf_cache_scan.partial_transport_for("model", repo_id, variant)
def _partial_transport_for_variant(
repo_id: str,
variant: str,
repo_cache_dir: Optional[Path] = None,
) -> Optional[str]:
return hf_cache_scan.partial_transport_for(
"model",
repo_id,
variant,
repo_cache_dir,
)
def _local_main_gguf_blobs_by_quant(repo_id: str) -> dict[str, dict[str, set[str]]]:
def _local_main_gguf_blobs_by_quant(
repo_id: str, repo_cache_dir: Optional[Path] = None
) -> dict[str, dict[str, set[str]]]:
"""Map quant -> repo-relative expected GGUF filename -> cached blob hashes.
Shared companions are copied into each main-quant bucket so update checks can
@ -313,6 +333,14 @@ def _local_main_gguf_blobs_by_quant(repo_id: str) -> dict[str, dict[str, set[str
continue
if str(getattr(repo_info, "repo_id", "")).lower() != target_lower:
continue
if repo_cache_dir is not None:
try:
if Path(repo_info.repo_path).resolve(strict = False) != repo_cache_dir.resolve(
strict = False
):
continue
except (AttributeError, OSError, RuntimeError, ValueError):
continue
for path, hashes in cache_inventory._repo_gguf_blob_map(
repo_info,
include_companions = True,
@ -388,6 +416,7 @@ def delete_variant_incomplete_blobs_result(
*,
extra_hashes: frozenset[str] = frozenset(),
companions: bool = True,
root: Optional[Path] = None,
) -> VariantIncompleteDeleteResult:
# With a sibling still downloading, ``companions=False`` keeps a shared mmproj
# from being unlinked out from under it; the repo's last delete reclaims it.
@ -409,8 +438,9 @@ def delete_variant_incomplete_blobs_result(
)
deleted = 0
# Destructive iterator: only the exact-case match (or abort if ambiguous),
# so a case-variant sibling repo's partials are never unlinked.
for entry in iter_destructive_repo_cache_dirs("model", repo_id):
# so a case-variant sibling repo's partials are never unlinked. ``root`` scopes
# the purge to one cache so a delete never touches another cache's partials.
for entry in iter_destructive_repo_cache_dirs("model", repo_id, root = root):
blobs_dir = entry / "blobs"
if not blobs_dir.is_dir():
continue
@ -425,15 +455,37 @@ def delete_variant_incomplete_blobs_result(
return VariantIncompleteDeleteResult(deleted = deleted, unresolved = False)
def _repo_cache_dir_for_request(repo_id: str, local_path: Optional[str]) -> Path:
"""Resolve the one Hub repo cache represented by this variant request."""
expected_name = repo_cache_dir_name("model", repo_id).lower()
if local_path:
try:
local = Path(local_path).expanduser().resolve(strict = False)
for candidate in (local, *local.parents):
if candidate.name.lower() == expected_name:
return candidate
except (OSError, RuntimeError, ValueError):
pass
from utils.hf_cache_settings import get_hf_cache_paths
return get_hf_cache_paths().hub_cache / repo_cache_dir_name("model", repo_id)
def _mark_empty_dir_cleanables(
repo_id: str, response: GgufVariantsResponse
repo_id: str,
response: GgufVariantsResponse,
repo_cache_dir: Optional[Path] = None,
) -> GgufVariantsResponse:
"""Surface empty leftover ``<quant>/`` folders (interrupted downloads) as
partial so the UI can delete them -- on local/offline paths too, not just a
remote listing. A listed quant is flipped to partial; an unlisted one is
appended as a zero-byte cleanable entry."""
try:
empty_labels = list_empty_gguf_variant_dirs(repo_id)
empty_labels = (
list_empty_gguf_variant_dirs(repo_id, root = repo_cache_dir.parent)
if repo_cache_dir is not None
else list_empty_gguf_variant_dirs(repo_id)
)
except Exception as e:
logger.warning(f"Failed to scan empty GGUF variant folders for {repo_id}: {e}")
return response
@ -468,6 +520,11 @@ async def get_gguf_variants_response(
"""
def _compute() -> GgufVariantsResponse:
repo_cache_dir = (
None if is_local_path(repo_id) else _repo_cache_dir_for_request(repo_id, local_path)
)
hub_cache = repo_cache_dir.parent if repo_cache_dir is not None else None
def _local_response(
response_repo_id: str, variants, has_vision: bool
) -> GgufVariantsResponse:
@ -511,6 +568,7 @@ async def get_gguf_variants_response(
partial_transport = _partial_transport_for_variant(
response_repo_id,
v.quant,
repo_cache_dir,
),
)
for v in variants
@ -532,7 +590,7 @@ async def get_gguf_variants_response(
local_only = prefer_local_cache or offline
if local_only:
cached = list_gguf_variants_from_hf_cache(repo_id)
cached = list_gguf_variants_from_hf_cache(repo_id, root = hub_cache)
if cached is not None:
variants, has_vision = cached
return _local_response(repo_id, variants, has_vision)
@ -540,7 +598,7 @@ async def get_gguf_variants_response(
variants, has_vision = list_local_gguf_variants(local_path)
if variants or has_vision:
return _local_response(repo_id, variants, has_vision)
partial = list_partial_gguf_variants_from_state(repo_id)
partial = list_partial_gguf_variants_from_state(repo_id, hub_cache = hub_cache)
if partial is not None:
variants, has_vision = partial
return _partial_local_response(repo_id, variants, has_vision)
@ -560,11 +618,11 @@ async def get_gguf_variants_response(
try:
variants, has_vision, siblings = list_gguf_variants(repo_id, hf_token = hf_token)
except Exception:
cached = list_gguf_variants_from_hf_cache(repo_id)
cached = list_gguf_variants_from_hf_cache(repo_id, root = hub_cache)
if cached is not None:
variants, has_vision = cached
return _local_response(repo_id, variants, has_vision)
partial = list_partial_gguf_variants_from_state(repo_id)
partial = list_partial_gguf_variants_from_state(repo_id, hub_cache = hub_cache)
if partial is not None:
variants, has_vision = partial
return _partial_local_response(repo_id, variants, has_vision)
@ -581,7 +639,7 @@ async def get_gguf_variants_response(
cached_filenames_by_snapshot: list[dict[str, int]] = []
cached_quant_bytes_by_snapshot: list[dict[str, int]] = []
if _is_valid_repo_id(repo_id):
for snap in iter_hf_cache_snapshots(repo_id):
for snap in iter_hf_cache_snapshots(repo_id, root = hub_cache):
try:
gguf_paths = list(_iter_gguf_paths(snap))
except (OSError, RuntimeError, ValueError) as e:
@ -694,11 +752,20 @@ async def get_gguf_variants_response(
partial_quants: set[str] = set()
partial_quant_transports: dict[str, Optional[str]] = {}
try:
incomplete_hashes = download_registry.incomplete_blob_hashes("model", repo_id)
incomplete_hashes = download_registry.incomplete_blob_hashes(
"model",
repo_id,
active_only = True,
root = hub_cache,
)
except Exception as e:
logger.warning(f"Failed to compute partial GGUF variants for {repo_id}: {e}")
incomplete_hashes = set()
scan_snapshot_dir = hf_cache_scan.resolve_snapshot_dir_for_scan("model", repo_id)
scan_snapshot_dir = hf_cache_scan.resolve_snapshot_dir_for_scan(
"model",
repo_id,
repo_cache_dir,
)
# Manifest + marker + main incomplete-blob check: catches variants whose
# download was cancelled or whose expected shards are missing/undersized.
for variant in variants:
@ -711,6 +778,7 @@ async def get_gguf_variants_response(
variant.quant,
hf_token,
include_companions = False,
repo_cache_dir = repo_cache_dir,
)
if hf_cache_scan.is_variant_partial(
repo_id,
@ -718,11 +786,13 @@ async def get_gguf_variants_response(
scan_snapshot_dir,
incomplete_blob_hashes = incomplete_hashes,
variant_blob_hashes = variant_hashes,
repo_cache_dir = repo_cache_dir,
):
partial_quants.add(variant.quant)
partial_quant_transports[variant.quant] = _partial_transport_for_variant(
repo_id,
variant.quant,
repo_cache_dir,
)
except Exception as e:
logger.warning(
@ -744,10 +814,14 @@ async def get_gguf_variants_response(
partial_quants.add(variant.quant)
partial_quant_transports.setdefault(
variant.quant,
_partial_transport_for_variant(repo_id, variant.quant),
_partial_transport_for_variant(
repo_id,
variant.quant,
repo_cache_dir,
),
)
local_blobs_by_quant = _local_main_gguf_blobs_by_quant(repo_id)
local_blobs_by_quant = _local_main_gguf_blobs_by_quant(repo_id, repo_cache_dir)
def _variant_detail(v) -> GgufVariantDetail:
is_partial = v.quant in partial_quants
@ -790,14 +864,20 @@ async def get_gguf_variants_response(
if skip:
raise
enriched = _mark_empty_dir_cleanables(
repo_id, GgufVariantsResponse(repo_id = repo_id, variants = [])
repo_id,
GgufVariantsResponse(repo_id = repo_id, variants = []),
_repo_cache_dir_for_request(repo_id, local_path),
)
if enriched.variants:
return enriched
raise
if skip:
return response
return _mark_empty_dir_cleanables(repo_id, response)
return _mark_empty_dir_cleanables(
repo_id,
response,
_repo_cache_dir_for_request(repo_id, local_path),
)
try:
return await asyncio.to_thread(_compute_with_cleanables)

View file

@ -106,11 +106,8 @@ def _is_model_directory_for_scan(path: Path, *, entry_limit: int | None) -> bool
def _resolve_hf_cache_dir() -> Path:
try:
from huggingface_hub.constants import HF_HUB_CACHE
return Path(HF_HUB_CACHE)
except Exception:
return Path.home() / ".cache" / "huggingface" / "hub"
from utils.hf_cache_settings import get_hf_cache_paths
return get_hf_cache_paths().hub_cache
def _scan_models_dir(
@ -202,7 +199,12 @@ def _hf_repo_dir_has_content(repo_dir: Path) -> bool:
return False
def _scan_hf_cache(cache_dir: Path, *, entry_limit: int | None = None) -> List[LocalModelInfo]:
def _scan_hf_cache(
cache_dir: Path,
*,
entry_limit: int | None = None,
active_cache: bool = True,
) -> List[LocalModelInfo]:
if not _safe_is_dir(cache_dir):
return []
@ -240,7 +242,10 @@ def _scan_hf_cache(cache_dir: Path, *, entry_limit: int | None = None) -> List[L
repo_dir,
)
gguf_partial = hf_cache_scan.is_gguf_repo_partial(model_id, repo_dir)
has_gguf_variant_state, gguf_variant_state_size = _gguf_variant_state_summary(model_id)
has_gguf_variant_state, gguf_variant_state_size = _gguf_variant_state_summary(
model_id,
hub_cache = cache_dir,
)
snapshot_partial_transport = (
hf_cache_scan.partial_transport_for(
"model",
@ -252,23 +257,25 @@ def _scan_hf_cache(cache_dir: Path, *, entry_limit: int | None = None) -> List[L
)
resolved = hf_cache_scan.resolve_hf_cache_realpath(repo_dir)
scan_path = Path(resolved) if resolved else repo_dir
load_path = repo_dir if active_cache else scan_path
# partial=False here; _apply_format_aware_partial below rewrites per-row
# so a hybrid repo's gguf row doesn't taint its safetensors row.
rows = _classify_local_path(
scan_path,
"hf_cache",
load_path = repo_dir,
load_path = load_path,
display_name = model_id.split("/")[-1],
model_id = model_id,
updated_at = updated_at,
partial = False,
active_cache = active_cache,
)
if not rows:
if has_gguf_variant_state and gguf_partial:
rows = [
_local_model_info(
scan_path = repo_dir,
load_path = repo_dir,
load_path = load_path,
source = "hf_cache",
model_format = "gguf",
display_name = model_id.split("/")[-1],
@ -277,6 +284,7 @@ def _scan_hf_cache(cache_dir: Path, *, entry_limit: int | None = None) -> List[L
partial = True,
requires_variant = True,
size_bytes = gguf_variant_state_size,
active_cache = active_cache,
)
]
else:
@ -285,13 +293,14 @@ def _scan_hf_cache(cache_dir: Path, *, entry_limit: int | None = None) -> List[L
rows = [
_local_model_info(
scan_path = repo_dir,
load_path = repo_dir,
load_path = load_path,
source = "hf_cache",
model_format = "unknown",
display_name = model_id.split("/")[-1],
model_id = model_id,
updated_at = updated_at,
partial = snapshot_partial or gguf_partial,
active_cache = active_cache,
)
]
elif (
@ -302,7 +311,7 @@ def _scan_hf_cache(cache_dir: Path, *, entry_limit: int | None = None) -> List[L
rows.append(
_local_model_info(
scan_path = repo_dir,
load_path = repo_dir,
load_path = load_path,
source = "hf_cache",
model_format = "gguf",
display_name = model_id.split("/")[-1],
@ -311,6 +320,7 @@ def _scan_hf_cache(cache_dir: Path, *, entry_limit: int | None = None) -> List[L
partial = True,
requires_variant = True,
size_bytes = gguf_variant_state_size,
active_cache = active_cache,
)
)
rows = _apply_format_aware_partial(
@ -515,14 +525,39 @@ async def _collect_models_from_default_sources(
local_models += await _scan_source("HF cache", _scan_hf_cache, hf_cache_dir)
if _safe_is_dir(legacy_hf) and legacy_hf.resolve() != hf_cache_dir.resolve():
local_models += await _scan_source("legacy HF cache", _scan_hf_cache, legacy_hf)
local_models += await _scan_source(
"legacy HF cache",
lambda path: _scan_hf_cache(path, active_cache = False),
legacy_hf,
)
if (
_safe_is_dir(hf_default)
and hf_default.resolve() != hf_cache_dir.resolve()
and hf_default.resolve() != legacy_hf.resolve()
):
local_models += await _scan_source("default HF cache", _scan_hf_cache, hf_default)
local_models += await _scan_source(
"default HF cache",
lambda path: _scan_hf_cache(path, active_cache = False),
hf_default,
)
from utils.hf_cache_settings import known_hf_hub_caches
seen_hf = {
os.path.normcase(str(path.resolve(strict = False)))
for path in (hf_cache_dir, legacy_hf, hf_default)
}
for previous_cache in known_hf_hub_caches():
key = os.path.normcase(str(previous_cache.resolve(strict = False)))
if key in seen_hf:
continue
seen_hf.add(key)
local_models += await _scan_source(
"previous HF cache",
lambda path: _scan_hf_cache(path, active_cache = False),
previous_cache,
)
for lm_dir in lm_dirs:
local_models += await _scan_source("LM Studio", _scan_lmstudio_dir, lm_dir)
@ -543,7 +578,11 @@ def _scan_custom_folder(folder_path: Path) -> List[LocalModelInfo]:
limit = _MAX_MODELS_PER_CUSTOM_FOLDER,
entry_limit = _MAX_CUSTOM_FOLDER_ENTRIES,
)
+ _scan_hf_cache(folder_path, entry_limit = _MAX_CUSTOM_FOLDER_ENTRIES)
+ _scan_hf_cache(
folder_path,
entry_limit = _MAX_CUSTOM_FOLDER_ENTRIES,
active_cache = False,
)
+ _scan_lmstudio_dir(folder_path, entry_limit = _MAX_CUSTOM_FOLDER_ENTRIES)
)
if m.model_format in supported_formats
@ -610,12 +649,20 @@ def _dedupe_local_models(local_models: List[LocalModelInfo]) -> list[LocalModelI
row_key = model.inventory_id or model.id
key = f"{row_key}\x00custom" if model.source == "custom" else row_key
existing = deduped.get(key)
if existing is None or _prefer_complete_larger(
model.partial,
model.size_bytes,
existing.partial,
existing.size_bytes,
):
prefer_candidate = existing is None
if existing is not None:
if model.partial != existing.partial:
prefer_candidate = not model.partial
elif (model.active_cache is True) != (existing.active_cache is True):
prefer_candidate = model.active_cache is True
else:
prefer_candidate = _prefer_complete_larger(
model.partial,
model.size_bytes,
existing.partial,
existing.size_bytes,
)
if prefer_candidate:
deduped[key] = model
return sorted(
deduped.values(),

View file

@ -86,9 +86,20 @@ def _snapshot_complete_on_disk(
return False
if variant is None and hf_cache_scan.repo_cache_dir_has_incomplete_blobs(entry):
return False
if download_manifest.has_cancel_marker(repo_type, repo_id, variant):
hub_cache = entry.parent
if download_manifest.has_cancel_marker(
repo_type,
repo_id,
variant,
hub_cache = hub_cache,
):
return False
manifest = download_manifest.read_manifest(repo_type, repo_id, variant)
manifest = download_manifest.read_manifest(
repo_type,
repo_id,
variant,
hub_cache = hub_cache,
)
if manifest is None:
return False
return download_manifest.verify_against_disk(manifest, snapshot_dir).ok
@ -118,6 +129,8 @@ def compute_snapshot_progress(
0,
int(getattr(metadata, "completed_baseline_bytes", 0) or 0),
)
metadata_hub_cache = getattr(metadata, "hub_cache", None)
active_root = Path(metadata_hub_cache) if metadata_hub_cache else None
expected_total = max(expected_bytes, 0)
# Always resolve the revision's blob hashes so stale blobs from a superseded
@ -134,11 +147,17 @@ def compute_snapshot_progress(
count_finalized_unscoped = variant is None
readings: list[tuple[int, int, Optional[str], bool]] = []
for entry in preferred_repo_cache_dirs(
repo_type,
repo_id,
force_active = force_active,
):
cache_dirs = (
preferred_repo_cache_dirs(
repo_type,
repo_id,
force_active = force_active,
active_root = active_root,
)
if active_root is not None
else preferred_repo_cache_dirs(repo_type, repo_id, force_active = force_active)
)
for entry in cache_dirs:
completed_bytes = 0
in_progress_bytes = 0
cache_path = hf_cache_scan.resolve_hf_cache_realpath(entry)

View file

@ -72,57 +72,115 @@ def test_dataset_cache_scan_merges_raw_and_processed_rows(monkeypatch):
assert rows[0]["partial"] is False
def test_delete_cached_dataset_attempts_all_roots_before_raising(monkeypatch):
def test_delete_cached_dataset_scopes_delete_to_selected_root(monkeypatch, tmp_path):
"""A dataset present in the active cache and a previously selected cache is
deleted only from the selected root, so the other cache's copy survives."""
calls = []
purged_state = []
target_hub = tmp_path / "active" / "hub"
other_hub = tmp_path / "previous" / "hub"
for hub in (target_hub, other_hub):
(hub / "datasets--Org--Data").mkdir(parents = True)
class _DeleteStrategy:
def __init__(self, label: str, fail: bool):
def __init__(self, label: str):
self.label = label
self.fail = fail
def execute(self):
calls.append(self.label)
if self.fail:
raise RuntimeError(f"{self.label} failed")
class _Cache:
def __init__(self, label: str, fail: bool):
self.cache_dir = label
self.repos = [
def _cache(label: str, hub):
return SimpleNamespace(
cache_dir = label,
repos = [
SimpleNamespace(
repo_type = "dataset",
repo_id = "Org/Data",
repo_path = str(hub / "datasets--Org--Data"),
revisions = [SimpleNamespace(commit_hash = f"{label}-rev")],
)
]
self.fail = fail
def delete_revisions(self, *_revisions):
return _DeleteStrategy(self.cache_dir, self.fail)
],
delete_revisions = lambda *_revs, _label = label: _DeleteStrategy(_label),
)
monkeypatch.setattr(
cache_inventory,
"_collect_hf_cache_scans",
lambda: ([_Cache("first", True), _Cache("second", False)], set()),
lambda: ([_cache("active", target_hub), _cache("previous", other_hub)], set()),
)
monkeypatch.setattr(
cache_inventory,
"_delete_processed_dataset_cache",
lambda _repo_id: (True, []),
lambda _repo_id, **_kwargs: (False, []),
)
monkeypatch.setattr(
cache_inventory.download_manifest,
"purge_all_state_for_repo",
lambda *_args: purged_state.append(True) or 1,
lambda *_args, **_kwargs: 0,
)
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: SimpleNamespace(hub_cache = target_hub),
)
monkeypatch.setattr(
"hub.utils.hf_cache_state.hf_cache_roots",
lambda: [target_hub, other_hub],
)
with pytest.raises(HTTPException) as exc_info:
cache_inventory._delete_cached_dataset_blocking("Org/Data")
result = cache_inventory._delete_cached_dataset_blocking("Org/Data")
assert exc_info.value.status_code == 500
assert calls == ["first", "second"]
assert purged_state == []
assert result == {"status": "deleted", "repo_id": "Org/Data"}
# Only the selected (active) cache's revision is deleted; the previous
# cache's copy is never touched.
assert calls == ["active"]
assert not (target_hub / "datasets--Org--Data").exists()
assert (other_hub / "datasets--Org--Data").exists()
def test_delete_processed_only_dataset_accepts_processed_cache_path(monkeypatch, tmp_path):
"""A processed-only dataset row sends its Arrow cache path (<owner>___<repo>
under HF_DATASETS_CACHE), which is not a Hub datasets-- dir. The delete must
accept it and run the processed-cache delete instead of raising 400."""
datasets_root = tmp_path / "datasets"
processed_dir = datasets_root / "Org___Data"
processed_dir.mkdir(parents = True)
# No Hub-cache copy exists; only the processed Arrow cache holds this repo.
monkeypatch.setattr(cache_inventory, "_collect_hf_cache_scans", lambda: ([], set()))
monkeypatch.setattr(cache_inventory, "_hf_datasets_cache_roots", lambda: [datasets_root])
processed_calls: list[str] = []
monkeypatch.setattr(
cache_inventory,
"_delete_processed_dataset_cache",
lambda repo_id, **_kwargs: (processed_calls.append(repo_id) or True, []),
)
result = cache_inventory._delete_cached_dataset_blocking("Org/Data", str(processed_dir))
assert result == {"status": "deleted", "repo_id": "Org/Data"}
assert processed_calls == ["Org/Data"]
def test_delete_processed_dataset_scopes_to_selected_root(monkeypatch, tmp_path):
"""A dataset processed under two HF_DATASETS_CACHE roots is deleted only from
the selected root; the copy under the other cache home survives (real delete,
not stubbed)."""
selected_root = tmp_path / "selected" / "datasets"
other_root = tmp_path / "other" / "datasets"
for root in (selected_root, other_root):
(root / "Org___Data").mkdir(parents = True)
monkeypatch.setattr(cache_inventory, "_collect_hf_cache_scans", lambda: ([], set()))
monkeypatch.setattr(
cache_inventory, "_hf_datasets_cache_roots", lambda: [selected_root, other_root]
)
result = cache_inventory._delete_cached_dataset_blocking(
"Org/Data", str(selected_root / "Org___Data")
)
assert result == {"status": "deleted", "repo_id": "Org/Data"}
assert not (selected_root / "Org___Data").exists() # the selected copy is deleted
assert (other_root / "Org___Data").exists() # the other cache home is untouched
def test_delete_cached_dataset_purges_blob_only_repo_dir(monkeypatch):
@ -139,22 +197,22 @@ def test_delete_cached_dataset_purges_blob_only_repo_dir(monkeypatch):
monkeypatch.setattr(
cache_inventory,
"_delete_processed_dataset_cache",
lambda _repo_id: (False, []),
lambda _repo_id, **_kwargs: (False, []),
)
monkeypatch.setattr(
cache_inventory,
"purge_repo_cache_dirs",
lambda _repo_type, repo_id: purged_dirs.append(repo_id) or True,
lambda _repo_type, repo_id, **_kwargs: purged_dirs.append(repo_id) or True,
)
monkeypatch.setattr(
cache_inventory,
"purge_partial_repo",
lambda *_args: False,
lambda *_args, **_kwargs: False,
)
monkeypatch.setattr(
cache_inventory.download_manifest,
"purge_all_state_for_repo",
lambda *_args: 0,
lambda *_args, **_kwargs: 0,
)
result = cache_inventory._delete_cached_dataset_blocking("Org/Data")
@ -172,22 +230,22 @@ def test_delete_cached_dataset_absent_everywhere_raises_404(monkeypatch):
monkeypatch.setattr(
cache_inventory,
"_delete_processed_dataset_cache",
lambda _repo_id: (False, []),
lambda _repo_id, **_kwargs: (False, []),
)
monkeypatch.setattr(
cache_inventory,
"purge_repo_cache_dirs",
lambda *_args: False,
lambda *_args, **_kwargs: False,
)
monkeypatch.setattr(
cache_inventory,
"purge_partial_repo",
lambda *_args: False,
lambda *_args, **_kwargs: False,
)
monkeypatch.setattr(
cache_inventory.download_manifest,
"purge_all_state_for_repo",
lambda *_args: 0,
lambda *_args, **_kwargs: 0,
)
with pytest.raises(HTTPException) as exc_info:

View file

@ -0,0 +1,62 @@
# 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 json
from types import SimpleNamespace
from hub.utils import download_manifest, state_dir
def _write_manifest(path, payload):
path.parent.mkdir(parents = True, exist_ok = True)
path.write_text(json.dumps(payload), encoding = "utf-8")
def test_purge_state_preserves_active_legacy_when_deleting_inactive_cache(monkeypatch, tmp_path):
"""A scoped delete of an inactive cache must not erase the unscoped legacy
state, which _legacy_state_applies attributes to the active cache."""
active = tmp_path / "active" / "hub"
previous = tmp_path / "previous" / "hub"
for path in (active, previous):
path.mkdir(parents = True)
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path / "state")
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: SimpleNamespace(hub_cache = str(active)),
)
# Unowned legacy manifest -> belongs to the active cache.
legacy = state_dir.manifest_path("model", "Org/Model")
_write_manifest(legacy, {"version": 1})
# The inactive cache's own scoped copy is the one being deleted.
scoped = state_dir.manifest_path("model", "Org/Model", hub_cache = str(previous))
_write_manifest(scoped, {"version": 1, "hub_cache": str(previous)})
removed = download_manifest.purge_state("model", "Org/Model", hub_cache = str(previous))
assert removed is True
assert not scoped.is_file() # the inactive cache's copy is gone
assert legacy.is_file() # the active cache's legacy state survives
def test_purge_state_removes_legacy_owned_by_the_deleted_cache(monkeypatch, tmp_path):
"""A legacy file that recorded the deleted cache as its owner is purged."""
active = tmp_path / "active" / "hub"
previous = tmp_path / "previous" / "hub"
for path in (active, previous):
path.mkdir(parents = True)
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path / "state")
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: SimpleNamespace(hub_cache = str(active)),
)
legacy = state_dir.manifest_path("model", "Org/Model")
_write_manifest(legacy, {"version": 1, "hub_cache": str(previous)})
removed = download_manifest.purge_state("model", "Org/Model", hub_cache = str(previous))
assert removed is True
assert not legacy.is_file() # owned by the deleted cache -> purged

View file

@ -120,10 +120,16 @@ def _force_compute_to_raise(monkeypatch):
monkeypatch.setattr(gguf_variants, "list_gguf_variants", _boom, raising = False)
monkeypatch.setattr(
gguf_variants, "list_gguf_variants_from_hf_cache", lambda repo_id: None, raising = False
gguf_variants,
"list_gguf_variants_from_hf_cache",
lambda repo_id, root = None: None,
raising = False,
)
monkeypatch.setattr(
gguf_variants, "list_partial_gguf_variants_from_state", lambda repo_id: None, raising = False
gguf_variants,
"list_partial_gguf_variants_from_state",
lambda repo_id, hub_cache = None: None,
raising = False,
)
@ -133,7 +139,11 @@ def test_get_variants_surfaces_cleanable_when_metadata_fails(monkeypatch):
import asyncio
_force_compute_to_raise(monkeypatch)
monkeypatch.setattr(gguf_variants, "list_empty_gguf_variant_dirs", lambda repo_id: {"UD-IQ1_S"})
monkeypatch.setattr(
gguf_variants,
"list_empty_gguf_variant_dirs",
lambda repo_id, root = None: {"UD-IQ1_S"},
)
resp = asyncio.run(
gguf_variants.get_gguf_variants_response(
@ -152,7 +162,11 @@ def test_get_variants_reraises_when_no_cleanable(monkeypatch):
from fastapi import HTTPException
_force_compute_to_raise(monkeypatch)
monkeypatch.setattr(gguf_variants, "list_empty_gguf_variant_dirs", lambda repo_id: set())
monkeypatch.setattr(
gguf_variants,
"list_empty_gguf_variant_dirs",
lambda repo_id, root = None: set(),
)
try:
asyncio.run(

View file

@ -2,6 +2,7 @@
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import asyncio
import json
import sys
from pathlib import Path
from types import SimpleNamespace
@ -102,6 +103,236 @@ def test_big_endian_detection_ignores_model_name_be_token():
)
def _cached_model_row(tmp_path: Path, *, partial: bool, active_cache: bool | None, size_bytes: int):
path = tmp_path / f"cache-{active_cache}-{partial}-{size_bytes}"
return model_common._local_model_info(
scan_path = path,
load_path = path,
source = "hf_cache",
model_format = "safetensors",
model_id = "Org/Model",
partial = partial,
active_cache = active_cache,
size_bytes = size_bytes,
)
@pytest.mark.parametrize("reverse", [False, True])
def test_local_inventory_prefers_complete_previous_cache_copy(tmp_path, reverse):
active_partial = _cached_model_row(
tmp_path,
partial = True,
active_cache = True,
size_bytes = 20,
)
previous_complete = _cached_model_row(
tmp_path,
partial = False,
active_cache = False,
size_bytes = 10,
)
rows = [active_partial, previous_complete]
if reverse:
rows.reverse()
result = local_inventory._dedupe_local_models(rows)
assert result == [previous_complete]
def test_local_inventory_compares_all_non_active_cache_copies(tmp_path):
inactive_partial = _cached_model_row(
tmp_path,
partial = True,
active_cache = False,
size_bytes = 20,
)
custom_complete = _cached_model_row(
tmp_path,
partial = False,
active_cache = None,
size_bytes = 10,
)
assert local_inventory._dedupe_local_models([inactive_partial, custom_complete]) == [
custom_complete
]
def test_local_inventory_prefers_active_cache_when_copies_are_equally_complete(tmp_path):
previous = _cached_model_row(
tmp_path,
partial = False,
active_cache = False,
size_bytes = 20,
)
active = _cached_model_row(
tmp_path,
partial = False,
active_cache = True,
size_bytes = 10,
)
assert local_inventory._dedupe_local_models([previous, active]) == [active]
def test_loaded_repo_match_accepts_previous_cache_snapshot_path(monkeypatch, tmp_path):
repo_dir = tmp_path / "old-hub" / "models--Org--Model"
snapshot = repo_dir / "snapshots" / "revision"
snapshot.mkdir(parents = True)
monkeypatch.setattr(deletion, "iter_repo_cache_dirs", lambda *_args: iter([repo_dir]))
assert deletion._loaded_id_matches_repo(str(snapshot), "Org/Model") is True
assert deletion._loaded_id_matches_repo(str(snapshot / "model.gguf"), "Org/Model") is True
assert deletion._loaded_id_matches_repo(str(tmp_path / "other"), "Org/Model") is False
def test_cached_inventory_loads_previous_cache_copy_by_snapshot(monkeypatch, tmp_path):
active_hub = tmp_path / "active-hub"
previous_repo = tmp_path / "previous-hub" / "models--Org--Model"
snapshot = previous_repo / "snapshots" / "revision"
snapshot.mkdir(parents = True)
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: SimpleNamespace(hub_cache = active_hub),
)
fields = cache_inventory._cache_inventory_fields(
"Org/Model",
"safetensors",
repo_path = previous_repo,
snapshot_path = snapshot,
)
assert fields["load_id"] == str(snapshot)
def test_cached_inventory_keeps_repo_id_for_active_cache(monkeypatch, tmp_path):
active_hub = tmp_path / "active-hub"
active_repo = active_hub / "models--Org--Model"
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: SimpleNamespace(hub_cache = active_hub),
)
fields = cache_inventory._cache_inventory_fields(
"Org/Model",
"safetensors",
repo_path = active_repo,
)
assert fields["load_id"] == "Org/Model"
def test_cached_inventory_prefers_active_copy_when_completeness_matches():
previous = {"partial": False, "active_cache": False, "size_bytes": 200}
active = {"partial": False, "active_cache": True, "size_bytes": 100}
assert cache_inventory._prefer_cache_row(active, previous) is True
assert cache_inventory._prefer_cache_row(previous, active) is False
def test_cached_inventory_prefers_complete_copy_before_active_cache():
previous = {"partial": False, "active_cache": False, "size_bytes": 100}
active_partial = {"partial": True, "active_cache": True, "size_bytes": 200}
assert cache_inventory._prefer_cache_row(previous, active_partial) is True
assert cache_inventory._prefer_cache_row(active_partial, previous) is False
def test_inventory_scans_every_dynamic_cache_root(monkeypatch, tmp_path):
first = tmp_path / "first-hub"
second = tmp_path / "second-hub"
unreadable = tmp_path / "unreadable-hub"
first.mkdir()
second.mkdir()
unreadable.mkdir()
scanned = []
monkeypatch.setattr(
inventory_scan,
"hf_cache_roots",
lambda: [first, unreadable, second],
)
def scan_cache(cache_dir):
path = Path(cache_dir)
scanned.append(path)
if path == unreadable:
raise PermissionError("unreadable")
return SimpleNamespace(cache_dir = cache_dir)
monkeypatch.setattr("huggingface_hub.scan_cache_dir", scan_cache)
result = inventory_scan._compute_all_hf_cache_scans()
assert scanned == [first, unreadable, second]
assert [Path(scan.cache_dir) for scan in result] == [first, second]
def test_inventory_applies_download_state_to_its_owning_cache(monkeypatch, tmp_path):
state_root = tmp_path / "state"
cache_a = tmp_path / "cache-a"
cache_b = tmp_path / "cache-b"
repo_id = "Org/Model"
repo_name = "models--Org--Model"
repo_a = cache_a / repo_name
repo_b = cache_b / repo_name
snapshot_a = repo_a / "snapshots" / "revision"
snapshot_b = repo_b / "snapshots" / "revision"
snapshot_a.mkdir(parents = True)
snapshot_b.mkdir(parents = True)
(snapshot_a / "config.json").write_bytes(b"x")
(snapshot_b / "config.json").write_bytes(b"xx")
monkeypatch.setattr(state_dir, "cache_root", lambda: state_root)
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: SimpleNamespace(hub_cache = cache_b),
)
assert download_manifest.write_manifest(
"model",
repo_id,
None,
[download_manifest.ExpectedFile(path = "config.json", size = 2)],
"http",
hub_cache = cache_a,
)
assert inventory_scan.is_snapshot_partial("model", repo_id, repo_a) is True
assert inventory_scan.is_snapshot_partial("model", repo_id, repo_b) is False
assert inventory_scan.partial_transport_for("model", repo_id, None, repo_a) == "http"
assert inventory_scan.partial_transport_for("model", repo_id, None, repo_b) is None
def test_inventory_scopes_cancel_markers_to_their_owning_cache(monkeypatch, tmp_path):
state_root = tmp_path / "state"
cache_a = tmp_path / "cache-a"
cache_b = tmp_path / "cache-b"
repo_id = "Org/Model"
repo_name = "models--Org--Model"
repo_a = cache_a / repo_name
repo_b = cache_b / repo_name
repo_a.mkdir(parents = True)
repo_b.mkdir(parents = True)
monkeypatch.setattr(state_dir, "cache_root", lambda: state_root)
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: SimpleNamespace(hub_cache = cache_b),
)
assert download_manifest.write_cancel_marker(
"model",
repo_id,
"Q4_K_M",
"xet",
hub_cache = cache_a,
)
assert inventory_scan.is_variant_partial(repo_id, "Q4_K_M", repo_cache_dir = repo_a) is True
assert inventory_scan.is_variant_partial(repo_id, "Q4_K_M", repo_cache_dir = repo_b) is False
def test_list_local_gguf_variants_skips_big_endian_sibling(tmp_path):
(tmp_path / "model-Q4_K_M-be.gguf").write_bytes(b"x" * 100)
(tmp_path / "model-Q4_K_M.gguf").write_bytes(b"y" * 10)
@ -163,8 +394,19 @@ def test_download_state_bounds_long_repo_variant_filenames(monkeypatch, tmp_path
"http",
)
marker_path = state_dir.marker_path("model", repo_id, variant)
manifest_path = state_dir.manifest_path("model", repo_id, variant)
hub_cache = download_manifest._canonical_hub_cache()
marker_path = state_dir.marker_path(
"model",
repo_id,
variant,
hub_cache = hub_cache,
)
manifest_path = state_dir.manifest_path(
"model",
repo_id,
variant,
hub_cache = hub_cache,
)
assert marker_path is not None
assert manifest_path is not None
@ -181,6 +423,97 @@ def test_download_state_bounds_long_repo_variant_filenames(monkeypatch, tmp_path
]
def test_download_state_isolated_across_hub_cache_switches(monkeypatch, tmp_path):
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path)
cache_a = tmp_path / "cache-a"
cache_b = tmp_path / "cache-b"
selected = SimpleNamespace(hub_cache = cache_a)
from utils import hf_cache_settings
monkeypatch.setattr(hf_cache_settings, "get_hf_cache_paths", lambda: selected)
expected_a = [download_manifest.ExpectedFile(path = "a.gguf", size = 1)]
expected_b = [download_manifest.ExpectedFile(path = "b.gguf", size = 2)]
assert download_manifest.write_manifest("model", "Owner/Repo", "Q4_K_M", expected_a)
assert download_manifest.write_cancel_marker("model", "Owner/Repo", "Q4_K_M", "http")
selected.hub_cache = cache_b
assert download_manifest.write_manifest("model", "Owner/Repo", "Q4_K_M", expected_b)
manifest_b = download_manifest.read_manifest("model", "Owner/Repo", "Q4_K_M")
manifest_a = download_manifest.read_manifest(
"model",
"Owner/Repo",
"Q4_K_M",
hub_cache = cache_a,
)
assert manifest_b is not None and manifest_b.expected_files == tuple(expected_b)
assert manifest_a is not None and manifest_a.expected_files == tuple(expected_a)
assert not download_manifest.has_cancel_marker("model", "Owner/Repo", "Q4_K_M")
assert download_manifest.has_cancel_marker(
"model",
"Owner/Repo",
"Q4_K_M",
hub_cache = cache_a,
)
assert len(list((tmp_path / "hub-state" / "manifests").rglob("*.json"))) == 2
def test_legacy_unscoped_download_state_falls_back_only_for_selected_cache(monkeypatch, tmp_path):
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path)
cache_a = tmp_path / "cache-a"
cache_b = tmp_path / "cache-b"
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: SimpleNamespace(hub_cache = cache_a),
)
manifest = state_dir.manifest_path("model", "Owner/Repo", "Q4_K_M")
marker = state_dir.marker_path("model", "Owner/Repo", "Q4_K_M")
assert manifest is not None and marker is not None
manifest.write_text(
json.dumps(
{
"version": 1,
"repo_id": "Owner/Repo",
"variant": "Q4_K_M",
"expected_files": [{"path": "model.gguf", "size": 10}],
"transport": "http",
}
),
encoding = "utf-8",
)
marker.write_text(
json.dumps({"version": 1, "repo_id": "Owner/Repo", "variant": "Q4_K_M"}),
encoding = "utf-8",
)
assert download_manifest.read_manifest("model", "Owner/Repo", "Q4_K_M") is not None
assert download_manifest.has_cancel_marker("model", "Owner/Repo", "Q4_K_M")
assert list(download_manifest.iter_variant_manifests("model", "Owner/Repo")) == [
("Q4_K_M", manifest)
]
assert list(download_manifest.iter_variant_markers("model", "Owner/Repo")) == [
("Q4_K_M", marker)
]
assert (
download_manifest.read_manifest(
"model",
"Owner/Repo",
"Q4_K_M",
hub_cache = cache_b,
)
is None
)
assert not download_manifest.has_cancel_marker(
"model",
"Owner/Repo",
"Q4_K_M",
hub_cache = cache_b,
)
class _RecordingLogger:
def __init__(self):
self.warnings = []
@ -416,8 +749,15 @@ def test_cached_gguf_scan_includes_variant_state_without_completed_gguf(monkeypa
"Q4_K_M",
[download_manifest.ExpectedFile(path = "model-Q4_K_M.gguf", size = 4096)],
"http",
hub_cache = repo_path.parent,
)
assert download_manifest.write_cancel_marker(
"model",
"Org/PartialGguf",
"Q4_K_M",
"http",
hub_cache = repo_path.parent,
)
assert download_manifest.write_cancel_marker("model", "Org/PartialGguf", "Q4_K_M", "http")
monkeypatch.setattr(
cache_inventory,
"all_hf_cache_scans",
@ -484,6 +824,7 @@ def test_cached_gguf_scan_keeps_infra_repo_with_user_downloaded_variant(monkeypa
"Q8_0",
[download_manifest.ExpectedFile(path = "bge-small-en-v1.5-Q8_0.gguf", size = 35_000_000)],
"http",
hub_cache = Path(embedder.repo_path).parent,
)
monkeypatch.setattr(
cache_inventory,
@ -1206,6 +1547,7 @@ def test_gguf_progress_counts_completed_mmproj_with_expected_bytes(monkeypatch,
),
],
"http",
hub_cache = entry.parent,
)
requirement = gguf_variants._GgufVariantRequirement(
@ -1292,6 +1634,7 @@ def test_gguf_progress_subtracts_new_job_completed_baseline(monkeypatch, tmp_pat
),
],
"http",
hub_cache = entry.parent,
)
requirement = gguf_variants._GgufVariantRequirement(
@ -1462,6 +1805,7 @@ def test_gguf_progress_complete_on_disk_ignores_full_baseline(monkeypatch, tmp_p
),
],
"http",
hub_cache = entry.parent,
)
requirement = gguf_variants._GgufVariantRequirement(
@ -1861,8 +2205,15 @@ def test_hf_cache_scan_uses_gguf_partial_row_for_variant_state(monkeypatch, tmp_
"Q4_K_M",
[download_manifest.ExpectedFile(path = "model-Q4_K_M.gguf", size = 8192)],
"http",
hub_cache = cache_dir,
)
assert download_manifest.write_cancel_marker(
"model",
"Org/PartialGguf",
"Q4_K_M",
"http",
hub_cache = cache_dir,
)
assert download_manifest.write_cancel_marker("model", "Org/PartialGguf", "Q4_K_M", "http")
monkeypatch.setattr(local_inventory, "_classify_local_path", lambda *_args, **_kwargs: [])
monkeypatch.setattr(
local_inventory.hf_cache_scan,
@ -2117,7 +2468,7 @@ def test_gguf_variants_partial_marker_overrides_size_only_downloaded(monkeypatch
monkeypatch.setattr(
gguf_variants,
"iter_hf_cache_snapshots",
lambda _repo_id: [snapshot],
lambda _repo_id, root = None: [snapshot],
)
monkeypatch.setattr(
gguf_variants,
@ -2136,6 +2487,70 @@ def test_gguf_variants_partial_marker_overrides_size_only_downloaded(monkeypatch
assert result.variants[0].partial is True
def test_gguf_variants_scopes_partial_state_to_requested_cache(monkeypatch, tmp_path):
async def _run_inline(fn, *args, **kwargs):
return fn(*args, **kwargs)
repo_id = "Org/SharedRepo"
repo_name = "models--Org--SharedRepo"
cache_a = tmp_path / "cache-a"
cache_b = tmp_path / "cache-b"
repo_a = cache_a / repo_name
snapshot_a = repo_a / "snapshots" / "revision"
snapshot_a.mkdir(parents = True)
(snapshot_a / "model-Q8_0.gguf").write_bytes(b"complete")
blobs_b = cache_b / repo_name / "blobs"
blobs_b.mkdir(parents = True)
(blobs_b / "q8-hash.incomplete").write_bytes(b"partial")
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path / "state")
monkeypatch.setattr(gguf_variants.asyncio, "to_thread", _run_inline)
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: SimpleNamespace(hub_cache = cache_b),
)
assert download_manifest.write_cancel_marker(
"model",
repo_id,
"Q8_0",
"http",
hub_cache = cache_b,
)
monkeypatch.setattr(
gguf_variants,
"list_gguf_variants",
lambda *_args, **_kwargs: (
[
SimpleNamespace(
filename = "model-Q8_0.gguf",
quant = "Q8_0",
display_label = None,
size_bytes = 8,
)
],
False,
[
SimpleNamespace(
rfilename = "model-Q8_0.gguf",
size = 8,
lfs = SimpleNamespace(sha256 = "q8-hash"),
)
],
),
)
monkeypatch.setattr(cache_inventory, "all_hf_cache_scans", lambda: [])
result = asyncio.run(
gguf_variants.get_gguf_variants_response(
repo_id,
local_path = str(repo_a),
)
)
assert result.variants[0].downloaded is True
assert result.variants[0].partial is False
def test_download_registry_repo_keys_are_case_insensitive():
registry = download_registry.DownloadRegistry()
@ -2444,6 +2859,34 @@ def test_prepare_cache_for_transport_purges_only_requested_hashes(monkeypatch, t
assert (blobs / "shared-mmproj.incomplete").exists()
def test_prepare_cache_for_transport_uses_captured_root(monkeypatch, tmp_path):
cache_a = tmp_path / "cache-a"
cache_b = tmp_path / "cache-b"
repo_name = "models--Org--Repo"
partial_a = cache_a / repo_name / "blobs" / "blob.incomplete"
partial_b = cache_b / repo_name / "blobs" / "blob.incomplete"
partial_a.parent.mkdir(parents = True)
partial_b.parent.mkdir(parents = True)
partial_a.write_bytes(b"a")
partial_b.write_bytes(b"b")
monkeypatch.setattr(
download_registry,
"hf_cache_root",
lambda create = False, root = None: root or cache_b,
)
purged = download_registry.prepare_cache_for_transport(
"model",
"Org/Repo",
download_registry.TRANSPORT_HTTP,
root = cache_a,
)
assert purged == 1
assert not partial_a.exists()
assert partial_b.exists()
def _vision_cache_root(monkeypatch, tmp_path):
root = tmp_path / "hub"
blobs = root / "models--Org--Vision" / "blobs"
@ -2802,6 +3245,47 @@ def test_shutdown_skips_marker_for_worker_that_exits_cleanly(monkeypatch):
assert markers == ["Org/Cut"]
def test_orphan_reaper_uses_worker_cache_root_after_setting_changes(monkeypatch, tmp_path):
workers = tmp_path / "workers"
workers.mkdir()
cache_a = tmp_path / "cache-a" / "hub"
cache_b = tmp_path / "cache-b" / "hub"
partial = cache_a / "models--Org--Model" / "blobs" / "abc.incomplete"
partial.parent.mkdir(parents = True)
partial.write_bytes(b"partial")
cache_b.mkdir(parents = True)
monkeypatch.setattr(state_dir, "workers_dir", lambda: workers)
monkeypatch.setattr(download_registry, "_process_alive", lambda _pid: False)
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: SimpleNamespace(hub_cache = cache_b),
)
markers = []
monkeypatch.setattr(
download_registry,
"persist_cancel_marker",
lambda *args, **kwargs: markers.append(args),
)
metadata = download_registry.DownloadMetadata(
repo_type = "model",
repo_id = "Org/Model",
variant = None,
transport = download_registry.TRANSPORT_HTTP,
hub_cache = str(cache_a),
xet_cache = str(tmp_path / "cache-a" / "xet"),
)
download_registry.write_worker_breadcrumb("org/model", 1234, metadata)
[breadcrumb] = list(workers.iterdir())
payload = json.loads(breadcrumb.read_text(encoding = "utf-8"))
assert payload["hub_cache"] == str(cache_a)
assert payload["xet_cache"] == str(tmp_path / "cache-a" / "xet")
download_registry.reap_orphan_workers()
assert markers == [("model", "Org/Model", None, "http")]
assert list(workers.iterdir()) == []
def test_model_claim_register_cancel_uses_registry_marker_owner(monkeypatch):
killed = []
@ -3125,12 +3609,19 @@ def _build_variant_cache_repo(repo_dir, blob_specs, snapshot_links):
return repo
def _patch_variant_delete_side_effects(monkeypatch):
def _patch_variant_delete_side_effects(monkeypatch, hub_cache = None):
monkeypatch.setattr(
deletion.download_manifest,
"purge_state",
lambda *_args, **_kwargs: False,
)
# The repo under test lives in this cache; make it the active one so the
# delete scopes to it (default target root is the active hub cache).
if hub_cache is not None:
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: SimpleNamespace(hub_cache = hub_cache),
)
def test_snapshot_progress_filters_stale_blobs(monkeypatch, tmp_path):
@ -3308,7 +3799,7 @@ def test_delete_variant_keeps_blob_shared_with_other_snapshot(monkeypatch, tmp_p
"all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [repo])],
)
_patch_variant_delete_side_effects(monkeypatch)
_patch_variant_delete_side_effects(monkeypatch, tmp_path)
result = deletion._delete_cached_model_blocking("Org/Repo-GGUF", "Q4_K_M", None)
@ -3335,7 +3826,7 @@ def test_delete_variant_unlinks_unshared_blob(monkeypatch, tmp_path):
"all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [repo])],
)
_patch_variant_delete_side_effects(monkeypatch)
_patch_variant_delete_side_effects(monkeypatch, tmp_path)
result = deletion._delete_cached_model_blocking("Org/Repo-GGUF", "Q4_K_M", None)
@ -3361,7 +3852,7 @@ def test_delete_variant_surfaces_locked_file_as_conflict(monkeypatch, tmp_path):
"all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [repo])],
)
_patch_variant_delete_side_effects(monkeypatch)
_patch_variant_delete_side_effects(monkeypatch, tmp_path)
real_unlink = Path.unlink

View file

@ -77,6 +77,7 @@ class Manifest:
started_at: str
expected_files: tuple[ExpectedFile, ...]
transport: Optional[str] = None
hub_cache: Optional[str] = None
@dataclass(frozen = True)
@ -86,6 +87,78 @@ class VerifyResult:
size_mismatched: tuple[str, ...]
def _canonical_hub_cache(hub_cache: Optional[str | Path] = None) -> Optional[str]:
if hub_cache is None:
try:
from utils.hf_cache_settings import get_hf_cache_paths
hub_cache = get_hf_cache_paths().hub_cache
except Exception:
return None
try:
return str(Path(hub_cache).expanduser().resolve(strict = False))
except (OSError, RuntimeError, ValueError):
return str(hub_cache)
def _read_state_payload(path: Path) -> Optional[dict]:
try:
data = json.loads(path.read_text(encoding = "utf-8"))
except (OSError, ValueError) as exc:
logger.debug("Could not read Hub state %s: %s", path, exc)
return None
return data if isinstance(data, dict) else None
def _legacy_state_applies(
path: Path,
requested_hub_cache: Optional[str],
*,
fail_closed: bool = False,
) -> bool:
"""Whether an old unscoped state file belongs to the requested cache.
Transitional files that recorded their cache keep that ownership. Older
files with no ownership can only be attributed to the currently selected
cache, which matches the single-cache behavior under which they were
written without leaking them into remembered inactive caches.
"""
data = _read_state_payload(path)
if data is not None:
recorded = data.get("hub_cache")
if isinstance(recorded, str) and recorded:
return _canonical_hub_cache(recorded) == requested_hub_cache
elif not fail_closed:
return False
return requested_hub_cache == _canonical_hub_cache()
def _state_read_path(
path_factory,
repo_type: RepoType,
repo_id: str,
variant: Optional[str],
hub_cache: Optional[str | Path],
*,
fail_closed: bool = False,
) -> Optional[Path]:
requested = _canonical_hub_cache(hub_cache)
scoped = path_factory(repo_type, repo_id, variant, hub_cache = requested)
try:
if scoped is not None and scoped.is_file():
return scoped
except OSError:
pass
legacy = path_factory(repo_type, repo_id, variant)
if legacy is None or legacy == scoped:
return None
try:
if not legacy.is_file():
return None
except OSError:
return None
return legacy if _legacy_state_applies(legacy, requested, fail_closed = fail_closed) else None
def _atomic_write_json(path: Path, payload: dict) -> bool:
# Per-write uuid suffix so a concurrent caller or a stale tmp from a
# previous crash cannot collide with the in-flight write.
@ -124,6 +197,8 @@ def write_manifest(
variant: Optional[str],
expected_files: Sequence[ExpectedFile],
transport: Optional[str] = None,
*,
hub_cache: Optional[str | Path] = None,
) -> bool:
"""Write/overwrite the manifest for this triple. Best-effort.
@ -131,7 +206,13 @@ def write_manifest(
worst-case fallback is the pre-fix scanner behavior (one missed
partial detection), which is no regression.
"""
path = manifest_path(repo_type, repo_id, variant)
recorded_hub_cache = _canonical_hub_cache(hub_cache)
path = manifest_path(
repo_type,
repo_id,
variant,
hub_cache = recorded_hub_cache,
)
if path is None:
return False
payload = {
@ -149,6 +230,7 @@ def write_manifest(
for f in expected_files
],
"transport": transport,
"hub_cache": recorded_hub_cache,
}
return _atomic_write_json(path, payload)
@ -157,6 +239,8 @@ def read_manifest(
repo_type: RepoType,
repo_id: str,
variant: Optional[str] = None,
*,
hub_cache: Optional[str | Path] = None,
) -> Optional[Manifest]:
"""Return the manifest if present and parseable; ``None`` otherwise.
@ -171,15 +255,17 @@ def read_manifest(
``_MANIFEST_VERSION`` and widen this check) or live under a different
filename, so an incompatible payload can never mis-classify rows.
"""
path = manifest_path(repo_type, repo_id, variant)
path = _state_read_path(
manifest_path,
repo_type,
repo_id,
variant,
hub_cache,
)
if path is None or not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding = "utf-8"))
except (OSError, ValueError) as exc:
logger.debug("Could not read manifest %s: %s", path, exc)
return None
if not isinstance(data, dict):
data = _read_state_payload(path)
if data is None:
return None
if data.get("version") != _MANIFEST_VERSION:
logger.debug(
@ -216,6 +302,7 @@ def read_manifest(
started_at = str(data.get("started_at", "")),
expected_files = tuple(expected),
transport = transport if transport in ("http", "xet") else None,
hub_cache = data.get("hub_cache") if isinstance(data.get("hub_cache"), str) else None,
)
@ -289,6 +376,8 @@ def write_cancel_marker(
repo_id: str,
variant: Optional[str] = None,
transport: Optional[str] = None,
*,
hub_cache: Optional[str | Path] = None,
) -> bool:
"""Record that this triple was cancelled. Idempotent across repeated cancels.
@ -296,7 +385,13 @@ def write_cancel_marker(
inventory rows so the UI labels HTTP retries as continuable and XET
retries as full redownloads. None is accepted for forward-compat.
"""
path = marker_path(repo_type, repo_id, variant)
recorded_hub_cache = _canonical_hub_cache(hub_cache)
path = marker_path(
repo_type,
repo_id,
variant,
hub_cache = recorded_hub_cache,
)
if path is None:
return False
payload = {
@ -306,6 +401,7 @@ def write_cancel_marker(
"variant": variant,
"transport": transport,
"cancelled_at": datetime.now(timezone.utc).isoformat(),
"hub_cache": recorded_hub_cache,
}
return _atomic_write_json(path, payload)
@ -314,6 +410,8 @@ def read_cancel_marker_transport(
repo_type: RepoType,
repo_id: str,
variant: Optional[str] = None,
*,
hub_cache: Optional[str | Path] = None,
) -> Optional[str]:
"""Return the transport recorded in the cancel marker, or ``None`` if no
marker exists or it is unreadable.
@ -330,15 +428,17 @@ def read_cancel_marker_transport(
``None`` keeps the neutral "Retry" label.
* Unknown future versions ``None`` (unknown layout, unknown transport).
"""
path = marker_path(repo_type, repo_id, variant)
path = _state_read_path(
marker_path,
repo_type,
repo_id,
variant,
hub_cache,
)
if path is None or not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding = "utf-8"))
except (OSError, ValueError) as exc:
logger.debug("Could not read cancel marker %s: %s", path, exc)
return None
if not isinstance(data, dict):
data = _read_state_payload(path)
if data is None:
return None
version = data.get("version")
if version == _LEGACY_MARKER_VERSION:
@ -351,10 +451,30 @@ def read_cancel_marker_transport(
return None
def _all_matching_state_paths(
parent: Optional[Path], repo_type: RepoType, repo_id: str, variant: Optional[str]
) -> tuple[Path, ...]:
if parent is None:
return ()
legacy_path = (
manifest_path(repo_type, repo_id, variant)
if parent.name == "manifests"
else marker_path(repo_type, repo_id, variant)
)
if legacy_path is None:
return ()
try:
return tuple(path for path in parent.rglob(legacy_path.name) if path.is_file())
except OSError:
return ()
def clear_cancel_marker(
repo_type: RepoType,
repo_id: str,
variant: Optional[str] = None,
*,
hub_cache: Optional[str | Path] = None,
) -> None:
"""Remove the cancel marker for this triple if present.
@ -362,31 +482,48 @@ def clear_cancel_marker(
download-start (a fresh attempt supersedes prior cancel state) and
again at successful completion (cleans up if the start clear failed).
"""
path = marker_path(repo_type, repo_id, variant)
if path is None:
return
try:
path.unlink(missing_ok = True)
except OSError as exc:
logger.debug("Could not clear cancel marker %s: %s", path, exc)
requested = _canonical_hub_cache(hub_cache)
path = marker_path(
repo_type,
repo_id,
variant,
hub_cache = requested,
)
legacy = marker_path(repo_type, repo_id, variant)
paths = [path]
if (
legacy is not None
and legacy != path
and _legacy_state_applies(legacy, requested, fail_closed = True)
):
paths.append(legacy)
for target in paths:
if target is None:
continue
try:
target.unlink(missing_ok = True)
except OSError as exc:
logger.debug("Could not clear cancel marker %s: %s", target, exc)
def has_cancel_marker(
repo_type: RepoType,
repo_id: str,
variant: Optional[str] = None,
*,
hub_cache: Optional[str | Path] = None,
) -> bool:
"""File-existence check only. Body is never read.
Fail-closed: a corrupt marker still returns ``True`` because the
file's existence is the signal (the user once cancelled this
triple, even if the body is unreadable).
"""
path = marker_path(repo_type, repo_id, variant)
if path is None:
return False
"""Return whether a cancel marker applies to the selected cache."""
path = _state_read_path(
marker_path,
repo_type,
repo_id,
variant,
hub_cache,
fail_closed = True,
)
try:
return path.is_file()
return path is not None and path.is_file()
except OSError:
return False
@ -395,48 +532,124 @@ def delete_manifest(
repo_type: RepoType,
repo_id: str,
variant: Optional[str] = None,
*,
hub_cache: Optional[str | Path] = None,
) -> bool:
path = manifest_path(repo_type, repo_id, variant)
if path is None:
return False
try:
if not path.is_file():
return False
path.unlink()
return True
except OSError as exc:
logger.debug("Could not delete manifest %s: %s", path, exc)
return False
requested = _canonical_hub_cache(hub_cache)
path = manifest_path(
repo_type,
repo_id,
variant,
hub_cache = requested,
)
legacy = manifest_path(repo_type, repo_id, variant)
paths = [path]
if legacy is not None and legacy != path and _legacy_state_applies(legacy, requested):
paths.append(legacy)
removed = False
for target in paths:
if target is None:
continue
try:
if target.is_file():
target.unlink()
removed = True
except OSError as exc:
logger.debug("Could not delete manifest %s: %s", target, exc)
return removed
def purge_state(
repo_type: RepoType,
repo_id: str,
variant: Optional[str] = None,
*,
hub_cache: Optional[str | Path] = None,
) -> bool:
"""Remove manifest + cancel marker for this triple. Returns ``True``
when anything was present on disk before the call. Idempotent."""
marker_existed = has_cancel_marker(repo_type, repo_id, variant)
manifest_removed = delete_manifest(repo_type, repo_id, variant)
clear_cancel_marker(repo_type, repo_id, variant)
return marker_existed or manifest_removed
when anything was present on disk before the call. Idempotent.
With ``hub_cache`` set, only that cache's scoped state (plus any legacy
unscoped file that belongs to it) is removed, so purging one cache's copy
never clears another cache's resumable/cancel state."""
if hub_cache is None:
paths = (
*_all_matching_state_paths(manifests_dir(), repo_type, repo_id, variant),
*_all_matching_state_paths(cancelled_dir(), repo_type, repo_id, variant),
)
else:
requested = _canonical_hub_cache(hub_cache)
candidates = [
manifest_path(repo_type, repo_id, variant, hub_cache = hub_cache),
marker_path(repo_type, repo_id, variant, hub_cache = hub_cache),
]
# Legacy unscoped state is shared: an unowned file belongs to the active
# cache (per _legacy_state_applies), so only purge it when it belongs to
# the cache being deleted -- else deleting an inactive cache would erase
# the active cache's resume/cancel state.
for path_factory in (manifest_path, marker_path):
legacy = path_factory(repo_type, repo_id, variant)
if legacy is not None and _legacy_state_applies(legacy, requested):
candidates.append(legacy)
paths = tuple(p for p in candidates if p is not None)
removed = False
for path in paths:
try:
if path.is_file():
path.unlink()
removed = True
except OSError as exc:
logger.debug("Could not purge Hub state %s: %s", path, exc)
return removed
def purge_all_state_for_repo(repo_type: RepoType, repo_id: str) -> int:
def purge_all_state_for_repo(
repo_type: RepoType,
repo_id: str,
*,
hub_cache: Optional[str | Path] = None,
) -> int:
"""Remove the snapshot-level manifest + marker AND every variant-keyed
manifest + marker for this repo. Used by the route delete handlers so
scanner state never outlives the cache it described. Returns the count
of (repo, variant) triples that had any state on disk."""
of (repo, variant) triples that had any state on disk.
With ``hub_cache`` set, only that cache's scoped state (plus any legacy
unscoped file) is enumerated and removed, so deleting one cache's copy does
not clear another cache's resumable/cancel state."""
removed = 0
if purge_state(repo_type, repo_id, None):
if purge_state(repo_type, repo_id, None, hub_cache = hub_cache):
removed += 1
variants: set[str] = set()
for variant, _ in iter_variant_manifests(repo_type, repo_id):
variants.add(variant)
for variant, _ in iter_variant_markers(repo_type, repo_id):
variants.add(variant)
prefix = variant_filename_prefix(repo_type, repo_id)
if hub_cache is None:
search = [(p, True) for p in (manifests_dir(), cancelled_dir()) if p is not None]
else:
# This cache's scoped dir (parent of its scoped path) plus the legacy
# unscoped base; glob (not rglob) so other caches' dirs are not swept.
search = []
for scoped, base in (
(manifest_path(repo_type, repo_id, None, hub_cache = hub_cache), manifests_dir()),
(marker_path(repo_type, repo_id, None, hub_cache = hub_cache), cancelled_dir()),
):
if scoped is not None:
search.append((scoped.parent, False))
if base is not None:
search.append((base, False))
for parent, recursive in search:
try:
entries = tuple(
parent.rglob(f"{prefix}*.json") if recursive else parent.glob(f"{prefix}*.json")
)
except OSError:
continue
for entry in entries:
if not entry.is_file():
continue
fallback = entry.stem[len(prefix) :]
variants.add(_variant_from_state_file(entry, fallback))
for variant in variants:
if purge_state(repo_type, repo_id, variant):
if purge_state(repo_type, repo_id, variant, hub_cache = hub_cache):
removed += 1
return removed
@ -453,35 +666,83 @@ def _variant_from_state_file(path: Path, fallback: str) -> str:
def _iter_variant_state_files(
parent: Optional[Path], repo_type: RepoType, repo_id: str
parent: Optional[Path],
repo_type: RepoType,
repo_id: str,
hub_cache: Optional[str | Path],
*,
cancel_markers: bool,
) -> Iterator[tuple[str, Path]]:
if parent is None:
return
prefix = variant_filename_prefix(repo_type, repo_id)
try:
entries = list(parent.iterdir())
except OSError:
path_factory = marker_path if cancel_markers else manifest_path
requested = _canonical_hub_cache(hub_cache)
scoped_probe = path_factory(
repo_type,
repo_id,
None,
hub_cache = requested,
)
if scoped_probe is None:
return
for entry in entries:
if not entry.is_file() or not entry.name.endswith(".json"):
prefix = variant_filename_prefix(repo_type, repo_id)
seen: set[str] = set()
for directory, legacy in ((scoped_probe.parent, False), (parent, True)):
if legacy and directory == scoped_probe.parent:
continue
stem = entry.name[: -len(".json")]
if not stem.lower().startswith(prefix):
try:
entries = list(directory.iterdir())
except OSError:
continue
variant = stem[len(prefix) :]
if variant:
yield _variant_from_state_file(entry, variant), entry
for entry in entries:
if not entry.is_file() or not entry.name.endswith(".json"):
continue
stem = entry.name[: -len(".json")]
if not stem.lower().startswith(prefix) or entry.name in seen:
continue
if legacy and not _legacy_state_applies(
entry,
requested,
fail_closed = cancel_markers,
):
continue
fallback = stem[len(prefix) :]
if fallback:
seen.add(entry.name)
yield _variant_from_state_file(entry, fallback), entry
def iter_variant_manifests(repo_type: RepoType, repo_id: str) -> Iterator[tuple[str, Path]]:
def iter_variant_manifests(
repo_type: RepoType,
repo_id: str,
*,
hub_cache: Optional[str | Path] = None,
) -> Iterator[tuple[str, Path]]:
"""Yield (variant, manifest_path) for every variant-keyed manifest
written for this repo. Used by is_gguf_repo_partial to enumerate all
variants present on disk so the all-variants-broken gate can run."""
yield from _iter_variant_state_files(manifests_dir(), repo_type, repo_id)
yield from _iter_variant_state_files(
manifests_dir(),
repo_type,
repo_id,
hub_cache,
cancel_markers = False,
)
def iter_variant_markers(repo_type: RepoType, repo_id: str) -> Iterator[tuple[str, Path]]:
def iter_variant_markers(
repo_type: RepoType,
repo_id: str,
*,
hub_cache: Optional[str | Path] = None,
) -> Iterator[tuple[str, Path]]:
"""Yield (variant, marker_path) for every variant-keyed cancel marker.
Companion to iter_variant_manifests: catches variants cancelled
before download-start ever wrote a manifest (very early failures)."""
yield from _iter_variant_state_files(cancelled_dir(), repo_type, repo_id)
yield from _iter_variant_state_files(
cancelled_dir(),
repo_type,
repo_id,
hub_cache,
cancel_markers = True,
)

View file

@ -129,6 +129,8 @@ def write_worker_breadcrumb(key: str, pid: int, metadata: Optional["DownloadMeta
"cancel_marker_transport": metadata.cancel_marker_transport
if metadata is not None
else None,
"hub_cache": metadata.hub_cache if metadata is not None else None,
"xet_cache": metadata.xet_cache if metadata is not None else None,
}
tmp = path.with_name(f".{path.name}.tmp-{pid}")
try:
@ -236,6 +238,7 @@ def _settle_orphaned_download(
repo_id: Optional[str],
variant: Optional[str],
transport: Optional[str],
hub_cache: Optional[str] = None,
) -> None:
"""Persist a cancel marker for a reaped orphan still mid-download so the next
launch settles it to a resumable "cancelled" state instead of a phantom-running
@ -251,18 +254,42 @@ def _settle_orphaned_download(
return
from hub.utils import download_manifest
manifest = download_manifest.read_manifest(repo_type, repo_id, variant)
cache_root = Path(hub_cache) if isinstance(hub_cache, str) and hub_cache else None
manifest = download_manifest.read_manifest(
repo_type,
repo_id,
variant,
hub_cache = cache_root,
)
if repo_type == "model" and variant and manifest is None:
return
if manifest is None:
if not has_active_incomplete_blobs(repo_type, repo_id):
if not has_active_incomplete_blobs(repo_type, repo_id, root = cache_root):
return
else:
if _manifest_verifies_against_active_cache(repo_type, repo_id, manifest):
if _manifest_verifies_against_active_cache(
repo_type,
repo_id,
manifest,
root = cache_root,
):
return
if not _manifest_has_active_incomplete_blobs(repo_type, repo_id, manifest):
if not _manifest_has_active_incomplete_blobs(
repo_type,
repo_id,
manifest,
root = cache_root,
):
return
persist_cancel_marker(repo_type, repo_id, variant, transport, logger = logger)
persist_cancel_marker(
repo_type,
repo_id,
variant,
transport,
hub_cache = hub_cache,
logger = logger,
)
def reap_orphan_workers() -> None:
@ -309,6 +336,7 @@ def reap_orphan_workers() -> None:
repo_id,
data.get("variant"),
data.get("cancel_marker_transport") or data.get("transport"),
data.get("hub_cache"),
)
except Exception as exc:
logger.debug("Reaper failed for breadcrumb %s: %s", entry, exc)
@ -355,8 +383,13 @@ def _purge_incomplete_blobs(
return removed
def _iter_active_snapshot_dirs(repo_type: str, repo_id: str) -> Iterator[Path]:
for entry in iter_active_repo_cache_dirs(repo_type, repo_id):
def _iter_active_snapshot_dirs(
repo_type: str,
repo_id: str,
*,
root: Optional[Path] = None,
) -> Iterator[Path]:
for entry in iter_active_repo_cache_dirs(repo_type, repo_id, root = root):
snapshots_dir = entry / "snapshots"
if not snapshots_dir.is_dir():
continue
@ -369,24 +402,41 @@ def _iter_active_snapshot_dirs(repo_type: str, repo_id: str) -> Iterator[Path]:
yield snapshot
def _manifest_verifies_against_active_cache(repo_type: str, repo_id: str, manifest) -> bool:
def _manifest_verifies_against_active_cache(
repo_type: str,
repo_id: str,
manifest,
*,
root: Optional[Path] = None,
) -> bool:
from hub.utils import download_manifest
for snapshot_dir in _iter_active_snapshot_dirs(repo_type, repo_id):
for snapshot_dir in _iter_active_snapshot_dirs(repo_type, repo_id, root = root):
if download_manifest.verify_against_disk(manifest, snapshot_dir).ok:
return True
return False
def _manifest_has_active_incomplete_blobs(repo_type: str, repo_id: str, manifest) -> bool:
def _manifest_has_active_incomplete_blobs(
repo_type: str,
repo_id: str,
manifest,
*,
root: Optional[Path] = None,
) -> bool:
if not getattr(manifest, "variant", None):
return has_active_incomplete_blobs(repo_type, repo_id)
return has_active_incomplete_blobs(repo_type, repo_id, root = root)
expected_hashes = frozenset(
expected.sha256 for expected in manifest.expected_files if expected.sha256
)
if not expected_hashes:
return has_active_incomplete_blobs(repo_type, repo_id)
return has_active_incomplete_blobs(repo_type, repo_id, root = root)
return bool(
incomplete_blob_hashes(repo_type, repo_id, active_only = True).intersection(expected_hashes)
incomplete_blob_hashes(
repo_type,
repo_id,
active_only = True,
root = root,
).intersection(expected_hashes)
)
@ -459,6 +509,7 @@ def prepare_cache_for_transport(
only_blob_hashes: Optional[frozenset[str]] = None,
companion_blob_hashes: Optional[frozenset[str]] = None,
protected_blob_hashes: Optional[frozenset[str]] = None,
root: Optional[Path] = None,
) -> int:
"""Guarantee any pre-existing ``.incomplete`` blobs are SAFE to resume under
*mode*. Returns the number of partial blobs purged for untrusted provenance.
@ -485,14 +536,13 @@ def prepare_cache_for_transport(
they are excluded from every purge so a shared companion is never deleted
mid-write.
Scope: only the active ``HF_HUB_CACHE`` root is inspected. That suffices for
resume safety because ``snapshot_download`` runs without a ``cache_dir``
override and so can only read or resume a ``.incomplete`` under this same
active root. Markers are written for the new mode before returning.
Scope: ``root`` selects the cache captured by the caller. It defaults to the
active ``HF_HUB_CACHE`` root for workers that inherit their cache through
the environment. Markers are written for the new mode before returning.
"""
if mode not in VALID_TRANSPORTS:
raise ValueError(f"Invalid transport mode: {mode!r}")
root = hf_cache_root(create = True)
root = hf_cache_root(create = True) if root is None else hf_cache_root(create = True, root = root)
if root is None:
return 0
target = target_dir_name(repo_type, repo_id)
@ -618,10 +668,11 @@ def incomplete_blob_hashes(
repo_id: str,
*,
active_only: bool = False,
root: Optional[Path] = None,
) -> set[str]:
out: set[str] = set()
entries = (
iter_active_repo_cache_dirs(repo_type, repo_id)
iter_active_repo_cache_dirs(repo_type, repo_id, root = root)
if active_only
else iter_repo_cache_dirs(repo_type, repo_id)
)
@ -638,16 +689,24 @@ def incomplete_blob_hashes(
return out
def completed_blob_bytes(repo_type: str, repo_id: str, blob_hashes: frozenset[str]) -> int:
"""Sum finalized blob bytes for *blob_hashes* in the active HF cache root.
def completed_blob_bytes(
repo_type: str,
repo_id: str,
blob_hashes: frozenset[str],
*,
root: Optional[Path] = None,
) -> int:
"""Sum finalized blob bytes for *blob_hashes* in a single HF cache root.
A worker only writes to the active ``HF_HUB_CACHE`` root, so a baseline must
ignore legacy/default roots that ``snapshot_download`` won't reuse this run.
A worker only writes to its captured ``HF_HUB_CACHE`` root, so a baseline
must be scoped to that root (``root``), not re-resolved to whatever cache is
active now; otherwise a runtime cache switch makes the retry baseline count
bytes from the wrong disk.
"""
if not blob_hashes:
return 0
total = 0
for entry in iter_active_repo_cache_dirs(repo_type, repo_id):
for entry in iter_active_repo_cache_dirs(repo_type, repo_id, root = root):
blobs_dir = entry / "blobs"
if not blobs_dir.is_dir():
continue
@ -712,6 +771,8 @@ class DownloadMetadata:
# Bytes already complete before this job started; not counted as this run's
# progress.
completed_baseline_bytes: int = 0
hub_cache: Optional[str] = None
xet_cache: Optional[str] = None
@dataclass(frozen = True)
@ -752,6 +813,7 @@ def persist_cancel_marker(
variant: Optional[str],
transport: Optional[str],
*,
hub_cache: Optional[str] = None,
logger = logger,
) -> None:
if not repo_type or not repo_id:
@ -763,6 +825,7 @@ def persist_cancel_marker(
repo_id,
variant,
transport = transport,
hub_cache = hub_cache,
):
logger.debug("write_cancel_marker returned False for %s", repo_id)
except Exception as exc:
@ -971,6 +1034,7 @@ class DownloadRegistry:
metadata_to_persist.repo_id,
metadata_to_persist.variant,
metadata_to_persist.transport,
hub_cache = metadata_to_persist.hub_cache,
)
return False
@ -1033,6 +1097,8 @@ class DownloadRegistry:
replace_active: bool = False,
metadata_transport: Optional[str] = None,
cancel_marker_transport: Optional[str] = None,
hub_cache: Optional[str] = None,
xet_cache: Optional[str] = None,
) -> tuple[bool, str]:
key = normalize_job_key(key)
repo = _repo_of_key(key)
@ -1106,6 +1172,8 @@ class DownloadRegistry:
0,
int(completed_baseline_bytes or 0),
),
hub_cache = hub_cache,
xet_cache = xet_cache,
)
if cancel_marker_transport is not None:
self._cancel_marker_transports[key] = cancel_marker_transport
@ -1386,6 +1454,7 @@ class DownloadRegistry:
metadata.repo_id,
metadata.variant,
metadata.cancel_marker_transport or metadata.transport,
hub_cache = metadata.hub_cache,
)
reaped: list[tuple[str, subprocess.Popen, Optional[DownloadMetadata]]] = []
for key, proc, metadata in live:
@ -1401,6 +1470,7 @@ class DownloadRegistry:
metadata.repo_id,
metadata.variant,
metadata.cancel_marker_transport or metadata.transport,
hub_cache = metadata.hub_cache,
)
continue
reaped.append((key, proc, metadata))
@ -1421,6 +1491,7 @@ class DownloadRegistry:
metadata.repo_id,
metadata.variant,
metadata.cancel_marker_transport or metadata.transport,
hub_cache = metadata.hub_cache,
)

View file

@ -253,11 +253,16 @@ def _env_offline() -> bool:
) or os.environ.get("TRANSFORMERS_OFFLINE", "").lower() in ("1", "true", "yes")
def iter_hf_cache_snapshots(repo_id: str):
from hub.utils.hf_cache_state import iter_repo_cache_dirs
def iter_hf_cache_snapshots(repo_id: str, root: Optional[Path] = None):
from hub.utils.hf_cache_state import iter_active_repo_cache_dirs, iter_repo_cache_dirs
snapshots: list[Path] = []
for repo_dir in iter_repo_cache_dirs("model", repo_id):
repo_dirs = (
iter_active_repo_cache_dirs("model", repo_id, root = root)
if root is not None
else iter_repo_cache_dirs("model", repo_id)
)
for repo_dir in repo_dirs:
snapshots_dir = repo_dir / "snapshots"
if not snapshots_dir.is_dir():
continue
@ -276,12 +281,17 @@ def iter_hf_cache_snapshots(repo_id: str):
yield from snapshots
def list_empty_gguf_variant_dirs(repo_id: str) -> set[str]:
def list_empty_gguf_variant_dirs(repo_id: str, root: Optional[Path] = None) -> set[str]:
"""Quant labels present only as an EMPTY snapshot ``<quant>/`` folder (an
interrupted split download); a quant with shards in any snapshot is excluded."""
empty: dict[str, str] = {}
nonempty: set[str] = set()
for snapshot in iter_hf_cache_snapshots(repo_id):
snapshots = (
iter_hf_cache_snapshots(repo_id, root = root)
if root is not None
else iter_hf_cache_snapshots(repo_id)
)
for snapshot in snapshots:
try:
entries = list(snapshot.iterdir())
except OSError:
@ -303,8 +313,15 @@ def list_empty_gguf_variant_dirs(repo_id: str) -> set[str]:
return {label for key, label in empty.items() if key not in nonempty}
def list_gguf_variants_from_hf_cache(repo_id: str) -> Optional[tuple[list[GgufVariantInfo], bool]]:
for snapshot in iter_hf_cache_snapshots(repo_id):
def list_gguf_variants_from_hf_cache(
repo_id: str, root: Optional[Path] = None
) -> Optional[tuple[list[GgufVariantInfo], bool]]:
snapshots = (
iter_hf_cache_snapshots(repo_id, root = root)
if root is not None
else iter_hf_cache_snapshots(repo_id)
)
for snapshot in snapshots:
variants, has_vision = list_local_gguf_variants(str(snapshot))
if variants or has_vision:
return variants, has_vision
@ -312,7 +329,7 @@ def list_gguf_variants_from_hf_cache(repo_id: str) -> Optional[tuple[list[GgufVa
def list_partial_gguf_variants_from_state(
repo_id: str,
repo_id: str, hub_cache: Optional[Path] = None
) -> Optional[tuple[list[GgufVariantInfo], bool]]:
"""Reconstruct GGUF variants from download manifests/markers alone.
@ -328,10 +345,26 @@ def list_partial_gguf_variants_from_state(
# original-casing label over a lowercased cancel marker for the same variant.
seen: set[str] = set()
ordered: list[str] = []
for source in (
download_manifest.iter_variant_manifests("model", repo_id),
download_manifest.iter_variant_markers("model", repo_id),
):
sources = (
(
download_manifest.iter_variant_manifests("model", repo_id),
download_manifest.iter_variant_markers("model", repo_id),
)
if hub_cache is None
else (
download_manifest.iter_variant_manifests(
"model",
repo_id,
hub_cache = hub_cache,
),
download_manifest.iter_variant_markers(
"model",
repo_id,
hub_cache = hub_cache,
),
)
)
for source in sources:
for variant, _path in source:
key = variant.lower()
if key not in seen:
@ -343,7 +376,16 @@ def list_partial_gguf_variants_from_state(
variants: list[GgufVariantInfo] = []
has_vision = False
for variant in ordered:
manifest = download_manifest.read_manifest("model", repo_id, variant)
manifest = (
download_manifest.read_manifest("model", repo_id, variant)
if hub_cache is None
else download_manifest.read_manifest(
"model",
repo_id,
variant,
hub_cache = hub_cache,
)
)
main_filename: Optional[str] = None
size_bytes = 0
companion_bytes = 0

View file

@ -29,12 +29,10 @@ def _safe_is_dir(path: Path) -> bool:
return False
def hf_cache_root(*, create: bool = False) -> Optional[Path]:
try:
from huggingface_hub import constants as hf_constants
except ImportError:
return None
root = Path(hf_constants.HF_HUB_CACHE)
def hf_cache_root(*, create: bool = False, root: Optional[Path] = None) -> Optional[Path]:
from utils.hf_cache_settings import get_hf_cache_paths
root = root or get_hf_cache_paths().hub_cache
if create:
try:
root.mkdir(parents = True, exist_ok = True)
@ -46,6 +44,7 @@ def hf_cache_root(*, create: bool = False) -> Optional[Path]:
def hf_cache_roots() -> list[Path]:
from hub.utils.paths import hf_default_cache_dir, legacy_hf_cache_dir
from utils.hf_cache_settings import known_hf_hub_caches
roots: list[Path] = []
seen: set[str] = set()
@ -62,7 +61,8 @@ def hf_cache_roots() -> list[Path]:
seen.add(key)
roots.append(path)
_add(hf_cache_root())
for configured in known_hf_hub_caches():
_add(configured)
_add(legacy_hf_cache_dir())
_add(hf_default_cache_dir())
return roots
@ -181,12 +181,22 @@ def iter_repo_cache_dirs(repo_type: str, repo_id: str) -> Iterator[Path]:
continue
def iter_destructive_repo_cache_dirs(repo_type: str, repo_id: str) -> Iterator[Path]:
def iter_destructive_repo_cache_dirs(
repo_type: str,
repo_id: str,
*,
root: Optional[Path] = None,
) -> Iterator[Path]:
target = repo_cache_dir_name(repo_type, repo_id)
folded_target = target.lower()
for root in hf_cache_roots():
if root is not None:
scoped = hf_cache_root(root = root)
bases = [scoped] if scoped is not None else []
else:
bases = hf_cache_roots()
for base in bases:
try:
entries = [entry for entry in root.iterdir() if entry.name.lower() == folded_target]
entries = [entry for entry in base.iterdir() if entry.name.lower() == folded_target]
except OSError:
continue
matched_names = resolve_destructive_case_matches(
@ -200,8 +210,13 @@ def iter_destructive_repo_cache_dirs(repo_type: str, repo_id: str) -> Iterator[P
yield entry
def iter_active_repo_cache_dirs(repo_type: str, repo_id: str) -> Iterator[Path]:
root = hf_cache_root()
def iter_active_repo_cache_dirs(
repo_type: str,
repo_id: str,
*,
root: Optional[Path] = None,
) -> Iterator[Path]:
root = hf_cache_root(root = root)
if root is None:
return
target = target_dir_name(repo_type, repo_id)
@ -218,12 +233,13 @@ def preferred_repo_cache_dirs(
repo_id: str,
*,
force_active: bool = False,
active_root: Optional[Path] = None,
) -> list[Path]:
active_entries = list(iter_active_repo_cache_dirs(repo_type, repo_id))
active_entries = list(iter_active_repo_cache_dirs(repo_type, repo_id, root = active_root))
if active_entries:
return active_entries
if force_active:
root = hf_cache_root()
root = hf_cache_root(root = active_root)
if root is not None:
canonical = repo_cache_dir_name(repo_type, repo_id)
return [root / canonical]
@ -237,8 +253,13 @@ def has_incomplete_blobs(repo_type: str, repo_id: str) -> bool:
return False
def has_active_incomplete_blobs(repo_type: str, repo_id: str) -> bool:
for entry in iter_active_repo_cache_dirs(repo_type, repo_id):
def has_active_incomplete_blobs(
repo_type: str,
repo_id: str,
*,
root: Optional[Path] = None,
) -> bool:
for entry in iter_active_repo_cache_dirs(repo_type, repo_id, root = root):
if repo_cache_dir_has_incomplete_blobs(entry):
return True
return False
@ -273,9 +294,14 @@ def _prune_empty_dirs(root: Path) -> bool:
return removed
def purge_partial_repo(repo_type: str, repo_id: str) -> bool:
def purge_partial_repo(
repo_type: str,
repo_id: str,
*,
root: Optional[Path] = None,
) -> bool:
removed = False
for entry in iter_destructive_repo_cache_dirs(repo_type, repo_id):
for entry in iter_destructive_repo_cache_dirs(repo_type, repo_id, root = root):
blobs_dir = entry / "blobs"
if blobs_dir.is_dir():
for blob in blobs_dir.iterdir():
@ -290,9 +316,14 @@ def purge_partial_repo(repo_type: str, repo_id: str) -> bool:
return removed
def purge_repo_cache_dirs(repo_type: str, repo_id: str) -> bool:
def purge_repo_cache_dirs(
repo_type: str,
repo_id: str,
*,
root: Optional[Path] = None,
) -> bool:
removed = False
for entry in iter_destructive_repo_cache_dirs(repo_type, repo_id):
for entry in iter_destructive_repo_cache_dirs(repo_type, repo_id, root = root):
try:
if entry.is_symlink() or not entry.is_dir():
continue
@ -301,3 +332,59 @@ def purge_repo_cache_dirs(repo_type: str, repo_id: str) -> bool:
except FileNotFoundError:
continue
return removed
def scoped_delete_root(repo_type: str, repo_id: str, cache_path: Optional[str]) -> Optional[Path]:
"""Resolve the single cache root a delete of this repo may touch.
Returns the active hub cache when *cache_path* is falsy, the owning cache
root when *cache_path* points inside a known cache, or ``None`` when
*cache_path* is set but not inside any known cache (caller should reject).
This keeps a delete of one inventory row from removing copies in other,
previously selected caches.
"""
from utils.hf_cache_settings import get_hf_cache_paths
if not cache_path:
return Path(get_hf_cache_paths().hub_cache).resolve(strict = False)
try:
resolved = Path(cache_path).expanduser().resolve(strict = False)
except (OSError, RuntimeError, ValueError):
return None
expected = repo_cache_dir_name(repo_type, repo_id).lower()
repo_dir = next(
(
candidate
for candidate in (resolved, *resolved.parents)
if candidate.name.lower() == expected
),
None,
)
if repo_dir is None:
return None
allowed = {r.resolve(strict = False) for r in hf_cache_roots()}
root = repo_dir.parent.resolve(strict = False)
return root if root in allowed else None
def resolve_delete_target_root(
repo_type: str, repo_id: str, cache_path: Optional[str], owner_roots
) -> Optional[Path]:
"""Pick the single cache root a delete of this repo should target.
An explicit *cache_path* wins (``None`` when it is not a known cache, so the
caller can reject it). Otherwise prefer the active cache when it holds a
copy, else the sole cache that does -- so a model that lives only in a
previously selected cache stays deletable while other caches are untouched.
"""
if cache_path:
return scoped_delete_root(repo_type, repo_id, cache_path)
from utils.hf_cache_settings import get_hf_cache_paths
active = Path(get_hf_cache_paths().hub_cache).resolve(strict = False)
roots = list(owner_roots)
if active in roots:
return active
if len(roots) == 1:
return roots[0]
return active

View file

@ -36,7 +36,7 @@ from hub.utils.state_dir import RepoType
from hub.utils.hf_cache_state import (
INCOMPLETE_SUFFIX,
has_incomplete_blobs,
hf_cache_root,
hf_cache_roots,
iter_repo_cache_dirs,
latest_snapshot_dir,
repo_cache_dir_has_incomplete_blobs,
@ -127,33 +127,13 @@ def all_hf_cache_scans() -> list:
def _compute_all_hf_cache_scans() -> list:
from huggingface_hub import scan_cache_dir
from hub.utils.paths import legacy_hf_cache_dir, hf_default_cache_dir
scans: list = []
seen: set[str] = set()
try:
from huggingface_hub.constants import HF_HUB_CACHE
active = Path(HF_HUB_CACHE).resolve()
seen.add(str(active))
if active.is_dir():
scans.append(scan_cache_dir())
except Exception as exc:
logger.warning("Could not scan active HF cache: %s", exc)
for extra_fn in (legacy_hf_cache_dir, hf_default_cache_dir):
for cache_root in hf_cache_roots():
try:
extra = extra_fn()
# is_dir()/resolve() can raise on an inaccessible path; skip it.
if not extra.is_dir():
continue
resolved = str(extra.resolve())
if resolved in seen:
continue
seen.add(resolved)
scans.append(scan_cache_dir(cache_dir = str(extra)))
scans.append(scan_cache_dir(cache_dir = str(cache_root)))
except Exception as exc:
logger.warning("Could not scan HF cache %s: %s", extra_fn.__name__, exc)
logger.warning("Could not scan HF cache %s: %s", cache_root, exc)
return scans
@ -224,16 +204,8 @@ def _compose_partial(*signals: Callable[[], bool]) -> bool:
return any(signal() for signal in signals)
def _state_applies_to_repo_cache_dir(repo_cache_dir: Optional[Path]) -> bool:
if repo_cache_dir is None:
return True
root = hf_cache_root()
if root is None:
return False
try:
return repo_cache_dir.resolve().parent == root.resolve()
except OSError:
return False
def _hub_cache_for_repo_dir(repo_cache_dir: Optional[Path]) -> Optional[Path]:
return repo_cache_dir.parent if repo_cache_dir is not None else None
def _legacy_partial(
@ -285,12 +257,24 @@ def _repo_cache_dir_has_non_gguf_broken_snapshot_symlinks(repo_cache_dir: Path)
return False
def _gguf_variant_manifest_blob_hashes(repo_id: str) -> frozenset[str]:
def _gguf_variant_manifest_blob_hashes(
repo_id: str, repo_cache_dir: Optional[Path] = None
) -> frozenset[str]:
from hub.utils import download_manifest
hashes: set[str] = set()
for variant, _path in download_manifest.iter_variant_manifests("model", repo_id):
manifest = download_manifest.read_manifest("model", repo_id, variant)
hub_cache = _hub_cache_for_repo_dir(repo_cache_dir)
for variant, _path in download_manifest.iter_variant_manifests(
"model",
repo_id,
hub_cache = hub_cache,
):
manifest = download_manifest.read_manifest(
"model",
repo_id,
variant,
hub_cache = hub_cache,
)
if manifest is None:
continue
for expected in manifest.expected_files:
@ -315,7 +299,7 @@ def _snapshot_legacy_partial(
) -> bool:
if repo_type != "model":
return _legacy_partial(repo_type, repo_id, repo_cache_dir)
ignored_hashes = _gguf_variant_manifest_blob_hashes(repo_id)
ignored_hashes = _gguf_variant_manifest_blob_hashes(repo_id, repo_cache_dir)
if repo_cache_dir is not None:
return _repo_cache_dir_has_snapshot_legacy_partial(
repo_cache_dir,
@ -375,9 +359,12 @@ def _manifest_partial(
) -> bool:
from hub.utils import download_manifest
if not _state_applies_to_repo_cache_dir(repo_cache_dir):
return False
manifest = download_manifest.read_manifest(repo_type, repo_id, variant)
manifest = download_manifest.read_manifest(
repo_type,
repo_id,
variant,
hub_cache = _hub_cache_for_repo_dir(repo_cache_dir),
)
if manifest is None:
return False
resolved = (
@ -452,10 +439,13 @@ def is_snapshot_partial(
A manifest without a resolvable snapshot is partial: the worker got
far enough to record expectations but did not leave a usable snapshot."""
from hub.utils import download_manifest
state_applies = _state_applies_to_repo_cache_dir(repo_cache_dir)
return _compose_partial(
lambda: state_applies and download_manifest.has_cancel_marker(repo_type, repo_id, None),
lambda: download_manifest.has_cancel_marker(
repo_type,
repo_id,
None,
hub_cache = _hub_cache_for_repo_dir(repo_cache_dir),
),
lambda: _snapshot_legacy_partial(repo_type, repo_id, repo_cache_dir),
lambda: _manifest_partial(
repo_type,
@ -484,10 +474,13 @@ def is_variant_partial(
caller is checking many variants of the same repo (see
is_gguf_repo_partial for that usage)."""
from hub.utils import download_manifest
state_applies = _state_applies_to_repo_cache_dir(repo_cache_dir)
return _compose_partial(
lambda: state_applies and download_manifest.has_cancel_marker("model", repo_id, variant),
lambda: download_manifest.has_cancel_marker(
"model",
repo_id,
variant,
hub_cache = _hub_cache_for_repo_dir(repo_cache_dir),
),
lambda: bool(
incomplete_blob_hashes
and variant_blob_hashes
@ -526,22 +519,38 @@ def is_gguf_repo_partial(repo_id: str, repo_cache_dir: Optional[Path] = None) ->
from hub.utils import download_manifest
has_legacy_partial = _legacy_partial("model", repo_id, repo_cache_dir)
state_applies = _state_applies_to_repo_cache_dir(repo_cache_dir)
snapshot_dir = resolve_snapshot_dir_for_scan(
"model",
repo_id,
repo_cache_dir,
)
variants: set[str] = set(_completed_gguf_variants(snapshot_dir))
if state_applies:
for variant, _path in download_manifest.iter_variant_manifests(
"model",
repo_id,
hub_cache = _hub_cache_for_repo_dir(repo_cache_dir)
for variant, _path in download_manifest.iter_variant_manifests(
"model",
repo_id,
hub_cache = hub_cache,
):
if (
download_manifest.read_manifest(
"model",
repo_id,
variant,
hub_cache = hub_cache,
)
is not None
):
variants.add(variant)
for variant, _path in download_manifest.iter_variant_markers(
for variant, _path in download_manifest.iter_variant_markers(
"model",
repo_id,
hub_cache = hub_cache,
):
if download_manifest.has_cancel_marker(
"model",
repo_id,
variant,
hub_cache = hub_cache,
):
variants.add(variant)
if not variants:
@ -576,14 +585,19 @@ def partial_transport_for(
available."""
from hub.utils import download_manifest
if not _state_applies_to_repo_cache_dir(repo_cache_dir):
return None
hub_cache = _hub_cache_for_repo_dir(repo_cache_dir)
marker_transport = download_manifest.read_cancel_marker_transport(
repo_type,
repo_id,
variant,
hub_cache = hub_cache,
)
if marker_transport is not None:
return marker_transport
manifest = download_manifest.read_manifest(repo_type, repo_id, variant)
manifest = download_manifest.read_manifest(
repo_type,
repo_id,
variant,
hub_cache = hub_cache,
)
return manifest.transport if manifest is not None else None

View file

@ -277,12 +277,8 @@ def _memo_drop(memo_key: tuple[str, str]) -> None:
def _hf_hub_cache_dir() -> Path:
try:
from huggingface_hub.constants import HF_HUB_CACHE
return Path(HF_HUB_CACHE)
except Exception as exc:
logger.debug("Could not read huggingface_hub HF_HUB_CACHE, using default: %s", exc)
return Path.home() / ".cache" / "huggingface" / "hub"
from utils.hf_cache_settings import get_hf_cache_paths
return get_hf_cache_paths().hub_cache
def _hf_hub_cache_dirs() -> list[Path]:
@ -300,7 +296,10 @@ def _hf_hub_cache_dirs() -> list[Path]:
seen.add(key)
roots.append(resolved)
_add(_hf_hub_cache_dir())
from utils.hf_cache_settings import known_hf_hub_caches
for configured in known_hf_hub_caches():
_add(configured)
try:
_add(legacy_hf_cache_dir())
_add(hf_default_cache_dir())

View file

@ -8,9 +8,10 @@ so it survives ``huggingface-cli delete-cache`` and any other HF-side
cache lifecycle. Two subdirectories:
<studio cache>/hub-state/
manifests/ <key>.json per-download expected-files manifest
cancelled/ <key>.json per-download cancel marker
manifests/cache-<digest>/<key>.json expected-files manifest
cancelled/cache-<digest>/<key>.json cancel marker
The cache digest isolates state for the same repo across selectable Hub caches.
The ``<key>`` mirrors HF's cache dir naming while the resulting manifest,
cancel-marker, and atomic-write temp filenames fit common filesystem basename
limits. Very long repo IDs use a stable hash in the state key:
@ -29,6 +30,7 @@ configuration failure.
from __future__ import annotations
import hashlib
import os
import re
from pathlib import Path
from typing import Literal, Optional, get_args
@ -55,6 +57,7 @@ _STATE_EXTENSION = ".json"
# _atomic_write_json writes ".<target>.tmp-<8hex>" beside the final file.
_ATOMIC_WRITE_TMP_OVERHEAD = len(".") + len(".tmp-") + 8
_MAX_VARIANT_FRAGMENT_LENGTH = 64
_CACHE_SCOPE_DIGEST_LENGTH = 32
def state_root() -> Optional[Path]:
@ -130,13 +133,32 @@ def _entry_key(repo_type: RepoType, repo_id: str, variant: Optional[str]) -> str
return f"{variant_filename_prefix(repo_type, repo_id)}{variant_fragment}"
def _cache_scope(parent: Path, hub_cache: Optional[str | Path]) -> Optional[Path]:
if hub_cache is None:
return parent
normalized = os.path.normcase(str(Path(hub_cache).expanduser()))
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:_CACHE_SCOPE_DIGEST_LENGTH]
scoped = parent / f"cache-{digest}"
try:
scoped.mkdir(parents = True, exist_ok = True)
except OSError as exc:
logger.debug("Could not create cache-scoped Hub state dir %s: %s", scoped, exc)
return None
return scoped
def manifest_path(
repo_type: RepoType,
repo_id: str,
variant: Optional[str] = None,
*,
hub_cache: Optional[str | Path] = None,
) -> Optional[Path]:
"""Path to the manifest file for this triple. May or may not exist."""
parent = _subdir(_MANIFESTS_SUBDIR)
if parent is None:
return None
parent = _cache_scope(parent, hub_cache)
if parent is None:
return None
return parent / f"{_entry_key(repo_type, repo_id, variant)}.json"
@ -146,9 +168,14 @@ def marker_path(
repo_type: RepoType,
repo_id: str,
variant: Optional[str] = None,
*,
hub_cache: Optional[str | Path] = None,
) -> Optional[Path]:
"""Path to the cancel-marker file for this triple. May or may not exist."""
parent = _subdir(_CANCELLED_SUBDIR)
if parent is None:
return None
parent = _cache_scope(parent, hub_cache)
if parent is None:
return None
return parent / f"{_entry_key(repo_type, repo_id, variant)}.json"

View file

@ -661,6 +661,7 @@ def _download_gguf_variant(repo_id: str, variant: str, hf_token: str | None, mod
variant,
plan.main_hashes,
hf_token,
hub_cache = Path(snapshot_path).parents[2],
)
except Exception as e:
print(

View file

@ -316,6 +316,7 @@ from routes import (
training_router,
)
from routes.llama import router as llama_router
from routes.whisper import router as whisper_router
from routes.preview import router as preview_router
from hub.routes import (
inventory_router as hub_inventory_router,
@ -762,6 +763,8 @@ app.add_middleware(SecurityHeadersMiddleware)
# headroom; non-upload routes keep the default body cap.
import json as _json_for_413 # noqa: E402
from utils.upload_limits import ( # noqa: E402
STT_AUDIO_JSON_MAX_BYTES,
STT_AUDIO_RAW_MAX_BYTES,
UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES,
default_request_body_limit_bytes,
upload_request_limit_bytes,
@ -800,6 +803,14 @@ def _get_upload_passthrough_request_max_bytes(path: str) -> int:
return default_request_body_limit_bytes()
def _get_request_body_max_bytes(path: str) -> int:
if path.startswith("/api/inference/audio/transcribe/raw"):
return STT_AUDIO_RAW_MAX_BYTES
if path.startswith("/api/inference/audio/transcribe"):
return STT_AUDIO_JSON_MAX_BYTES
return default_request_body_limit_bytes()
async def _send_411(send) -> None:
payload = _json_for_413.dumps(
{"detail": "Content-Length required for upload requests."},
@ -842,12 +853,14 @@ class MaxBodyMiddleware:
app,
max_bytes_getter,
protected_prefixes: tuple,
request_max_bytes_getter = None,
upload_passthrough_prefixes: tuple = (),
upload_passthrough_max_bytes_getter = None,
):
self.app = app
self.max_bytes_getter = max_bytes_getter
self.protected_prefixes = protected_prefixes
self.request_max_bytes_getter = request_max_bytes_getter
self.upload_passthrough_prefixes = upload_passthrough_prefixes
self.upload_passthrough_max_bytes_getter = upload_passthrough_max_bytes_getter
@ -864,6 +877,14 @@ class MaxBodyMiddleware:
except Exception:
return int(self.max_bytes_getter())
def _request_max_bytes(self, path: str) -> int:
if self.request_max_bytes_getter is None:
return int(self.max_bytes_getter())
try:
return int(self.request_max_bytes_getter(path))
except Exception:
return int(self.max_bytes_getter())
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
@ -876,7 +897,7 @@ class MaxBodyMiddleware:
await self.app(scope, receive, send)
return
max_bytes = int(self.max_bytes_getter())
max_bytes = self._request_max_bytes(path)
declared = None
for name, value in scope.get("headers", []):
if name == b"content-length":
@ -941,6 +962,7 @@ app.add_middleware(
MaxBodyMiddleware,
max_bytes_getter = default_request_body_limit_bytes,
protected_prefixes = _BODY_PROTECTED_PREFIXES,
request_max_bytes_getter = _get_request_body_max_bytes,
upload_passthrough_prefixes = _BODY_UPLOAD_PASSTHROUGH_PREFIXES,
upload_passthrough_max_bytes_getter = _get_upload_passthrough_request_max_bytes,
)
@ -999,6 +1021,7 @@ app.include_router(prompts_router, prefix = "/api/prompts", tags = ["prompts"])
app.include_router(datasets_router, prefix = "/api/datasets", tags = ["datasets"])
app.include_router(data_recipe_router, prefix = "/api/data-recipe", tags = ["data-recipe"])
app.include_router(llama_router, prefix = "/api/llama", tags = ["llama"])
app.include_router(whisper_router, prefix = "/api/whisper", tags = ["whisper"])
app.include_router(export_router, prefix = "/api/export", tags = ["export"])
app.include_router(rag_router, prefix = "/api/rag", tags = ["rag"])
app.include_router(training_history_router, prefix = "/api/train", tags = ["training-history"])

View file

@ -187,6 +187,32 @@ class UnloadRequest(BaseModel):
model_path: str = Field(..., description = "Model identifier to unload")
class TranscribeRequest(BaseModel):
"""Speech-to-text request for the dictation STT sidecar."""
audio: str = Field(..., description = "Base64-encoded audio (any common format)")
model: Optional[str] = Field(None, description = "STT model id; defaults server-side")
language: Optional[str] = Field(None, description = "BCP-47 language, or 'auto'/None to detect")
fast: bool = Field(
False,
description = "Use low-latency single-candidate decoding for dictation",
)
engine: Optional[str] = Field(
None,
description = "STT engine: 'transformers' (default) or 'gguf' (whisper.cpp)",
)
class SttLoadRequest(BaseModel):
"""Warm the STT sidecar with a model without transcribing."""
model: Optional[str] = Field(None, description = "STT model id; defaults server-side")
engine: Optional[str] = Field(
None,
description = "STT engine: 'transformers' (default) or 'gguf' (whisper.cpp)",
)
class ValidateModelRequest(BaseModel):
"""Check whether an identifier resolves to a ModelConfig; does NOT load weights."""

View file

@ -178,6 +178,14 @@ class LocalModelInfo(BaseModel):
None,
description = "HF repo id for cached models, e.g. org/model",
)
active_cache: Optional[bool] = Field(
None,
description = "Whether an HF model belongs to the current download cache.",
)
partial: bool = Field(
False,
description = "Whether the cached model has an incomplete download.",
)
model_format: Optional[str] = Field(
None,
description = "Detected weights format ('gguf' when known). Lets the UI "

View file

@ -22,6 +22,7 @@ from utils.models.model_config import (
_is_mmproj,
_is_mtp_drafter,
)
from utils.hf_cache_settings import active_hf_hub_cache
from utils.paths.path_utils import (
is_local_path,
normalize_path,
@ -378,7 +379,12 @@ def read_default_chat_template(
if _remote_exceeds_cap(rel):
return None
try:
path = hf_hub_download(resolved, rel, token = hf_token)
path = hf_hub_download(
resolved,
rel,
token = hf_token,
cache_dir = active_hf_hub_cache(),
)
return _read_bounded_text(Path(path), MAX_TEMPLATE_METADATA_BYTES)
except Exception:
return None

View file

@ -10,6 +10,7 @@ omegaconf
einx
pyloudnorm
openai-whisper
av # PyAV: decode dictation audio (webm/opus/mp3/…) for the Whisper STT sidecar
uroman # 4.0 MB - used for Outetts.
MeCab # 19.9 MB - used for Outetts.
inflect # number-to-words, required by OuteTTS

View file

@ -7,7 +7,7 @@
# (current PyPI metadata still declares torch as a hard dep).
# unsloth direct deps (from pyproject.toml [project].dependencies)
typer
typer>=0.12.0
# typer's full runtime dep tree. Required explicitly because this
# file is installed with --no-deps. On Linux/Mac CI runners these
# are often cached transitively; on a fresh windows-latest venv they

View file

@ -494,6 +494,11 @@ async def change_password(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Current password is incorrect",
)
if any(ch.isspace() for ch in payload.new_password):
raise HTTPException(
status_code = status.HTTP_400_BAD_REQUEST,
detail = "New password cannot contain spaces",
)
if payload.current_password == payload.new_password:
raise HTTPException(
status_code = status.HTTP_400_BAD_REQUEST,

View file

@ -23,40 +23,6 @@ def _is_valid_repo_id(repo_id: str) -> bool:
return bool(_VALID_REPO_ID.fullmatch(repo_id))
_dataset_size_cache: dict[str, int] = {}
def _get_dataset_size_cached(repo_id: str) -> int:
if repo_id in _dataset_size_cache:
return _dataset_size_cache[repo_id]
try:
from huggingface_hub import dataset_info as hf_dataset_info
info = hf_dataset_info(repo_id, token = None, files_metadata = True)
total = sum(s.size for s in info.siblings if getattr(s, "size", None))
_dataset_size_cache[repo_id] = total
return total
except Exception:
return 0
def _resolve_hf_cache_realpath(repo_dir: Path) -> Optional[str]:
"""Resolved realpath for a HF cache repo dir: most-recent snapshot, else cache root.
Mirrors routes/models.py; duplicated here to keep this module self-contained.
"""
try:
snapshots_dir = repo_dir / "snapshots"
if snapshots_dir.is_dir():
snaps = [s for s in snapshots_dir.iterdir() if s.is_dir()]
if snaps:
latest = max(snaps, key = lambda s: s.stat().st_mtime)
return str(latest.resolve())
return str(repo_dir.resolve())
except Exception:
return None
backend_path = Path(__file__).parent.parent.parent
if str(backend_path) not in sys.path:
sys.path.insert(0, str(backend_path))
@ -64,6 +30,7 @@ if str(backend_path) not in sys.path:
from utils.datasets import check_dataset_format
from utils.upload_limits import get_upload_limit_bytes, get_upload_limit_label
from auth.authentication import get_current_subject
from hub.dependencies import get_hf_token
router = APIRouter()
logger = get_logger(__name__)
@ -292,11 +259,13 @@ def _download_hf_metadata(*, repo_id: str, repo_files: list[str], token: str | N
try:
from huggingface_hub import hf_hub_download
from utils.hf_cache_settings import active_hf_hub_cache
local_path = hf_hub_download(
repo_id = repo_id,
filename = metadata_file,
repo_type = "dataset",
token = token,
cache_dir = active_hf_hub_cache(),
)
except Exception as exc:
logger.warning(f"Could not read HF dataset metadata for {repo_id}: {exc}")
@ -525,77 +494,15 @@ def list_local_datasets(
@router.get("/download-progress")
async def get_dataset_download_progress(
repo_id: str = Query(..., description = "HuggingFace dataset repo ID, e.g. 'unsloth/LaTeX_OCR'"),
hf_token: Optional[str] = Depends(get_hf_token),
current_subject: str = Depends(get_current_subject),
):
"""Return download progress for a HuggingFace dataset repo.
Mirrors ``GET /api/models/download-progress`` but scans the
``datasets--owner--name`` cache dir under HF_HUB_CACHE, where in-progress
download bytes are visible. Returns ``cache_path`` so the UI can show it.
"""
_empty = {
"downloaded_bytes": 0,
"expected_bytes": 0,
"progress": 0,
"cache_path": None,
}
try:
if not _is_valid_repo_id(repo_id):
return _empty
from huggingface_hub import constants as hf_constants
cache_dir = Path(hf_constants.HF_HUB_CACHE)
target = f"datasets--{repo_id.replace('/', '--')}".lower()
completed_bytes = 0
in_progress_bytes = 0
cache_path: Optional[str] = None
if cache_dir.is_dir():
for entry in cache_dir.iterdir():
if entry.name.lower() != target:
continue
cache_path = _resolve_hf_cache_realpath(entry)
blobs_dir = entry / "blobs"
if not blobs_dir.is_dir():
break
for f in blobs_dir.iterdir():
if not f.is_file():
continue
if f.name.endswith(".incomplete"):
in_progress_bytes += f.stat().st_size
else:
completed_bytes += f.stat().st_size
break
downloaded_bytes = completed_bytes + in_progress_bytes
if downloaded_bytes == 0:
return {**_empty, "cache_path": cache_path}
expected_bytes = _get_dataset_size_cached(repo_id)
if expected_bytes <= 0:
return {
"downloaded_bytes": downloaded_bytes,
"expected_bytes": 0,
"progress": 0,
"cache_path": cache_path,
}
# 95% threshold (as in the model endpoint): HF blob dedup makes
# completed_bytes drift under expected_bytes; inter-file gaps look "done".
if completed_bytes >= expected_bytes * 0.95:
progress = 1.0
else:
progress = min(downloaded_bytes / expected_bytes, 0.99)
return {
"downloaded_bytes": downloaded_bytes,
"expected_bytes": expected_bytes,
"progress": round(progress, 3),
"cache_path": cache_path,
}
except Exception as e:
logger.warning(f"Error checking dataset download progress for {repo_id}: {e}")
return _empty
"""Compatibility route backed by the shared multi-cache progress service."""
from hub.services.datasets import downloads
return await downloads.get_dataset_download_progress_response(
repo_id,
hf_token = hf_token,
)
@router.post("/check-format", response_model = CheckFormatResponse)

View file

@ -29,6 +29,8 @@ import re as _re
from utils.models import extract_model_size_b as _extract_model_size_b
from utils.api_errors import openai_error_body, anthropic_error_body
from utils.upload_limits import STT_AUDIO_B64_MAX_CHARS, STT_AUDIO_RAW_MAX_BYTES
from hub.dependencies import get_hf_token
from core.inference.orchestrator import GenStreamError, GenStreamErrorRaised
from core.inference.llama_admission import (
LlamaAdmissionCancelled,
@ -1692,6 +1694,8 @@ async def _aiter_llama_stream_items(
from models.inference import (
LoadRequest,
UnloadRequest,
TranscribeRequest,
SttLoadRequest,
GenerateRequest,
LoadResponse,
LoadProgressResponse,
@ -6121,6 +6125,342 @@ async def generate_audio(
)
# =====================================================================
# Speech-to-text (STT) sidecar (/audio/transcribe, /audio/stt/*)
# =====================================================================
def _resolve_stt_engine(engine: Optional[str]) -> str:
"""Normalize the requested STT engine name; default is Transformers."""
normalized = (engine or "transformers").strip().lower()
if normalized in ("", "transformers", "whisper"):
return "transformers"
if normalized in ("gguf", "ggml", "whisper_cpp", "whisper.cpp"):
return "gguf"
raise HTTPException(
status_code = 422,
detail = f"Unknown STT engine '{engine}'. Use 'transformers' or 'gguf'.",
)
def _resolve_serving_stt_engine(engine: Optional[str]) -> str:
"""Resolve the engine that will actually serve a model.
whisper.cpp (gguf) only accepts curated ids, which Transformers serves too,
so when whisper-server is not installed (the common case: `unsloth studio
update` does not yet build it) fall back to Transformers instead of 501-ing
on every recording. Used for download/load/transcribe; unload targets a
specific engine via _resolve_stt_engine.
"""
resolved = _resolve_stt_engine(engine)
if resolved == "gguf":
from core.inference import stt_ggml_sidecar
if not stt_ggml_sidecar.is_available():
return "transformers"
return resolved
def _stt_sidecar_for(engine: str):
if engine == "gguf":
from core.inference.stt_ggml_sidecar import get_ggml_stt_sidecar
return get_ggml_stt_sidecar()
from core.inference.stt_sidecar import get_stt_sidecar
return get_stt_sidecar()
@studio_router.get("/audio/stt/status")
async def stt_status(
model: Optional[str] = None, current_subject: str = Depends(get_current_subject)
):
"""Report STT availability and which model, if any, is resident.
``model`` extends the Transformers ``downloaded_models`` check to a
custom Hugging Face repository beyond the curated defaults.
"""
from core.inference import stt_ggml_sidecar, stt_sidecar
from core.inference.stt_sidecar import (
DEFAULT_STT_MODEL,
STT_MODELS,
get_stt_sidecar,
is_available,
)
sidecar = get_stt_sidecar()
ggml = stt_ggml_sidecar.get_ggml_stt_sidecar()
transformers_downloaded = [
model_id for model_id in STT_MODELS if stt_sidecar.is_model_downloaded(model_id)
]
if model and model not in STT_MODELS and stt_sidecar.is_model_downloaded(model):
transformers_downloaded.append(model)
return JSONResponse(
content = {
"available": is_available(),
"loaded_model": sidecar.loaded_model,
"loading": sidecar.is_loading(),
"device": sidecar.device,
"keep_alive_seconds": sidecar.keep_alive_seconds,
"default_model": DEFAULT_STT_MODEL,
"models": list(STT_MODELS.keys()),
# Transformers engine, same shape as "gguf" below so clients read
# either generically. Top-level fields above kept for old clients.
"transformers": {
"available": is_available(),
"loaded_model": sidecar.loaded_model,
"loading": sidecar.is_loading(),
"device": sidecar.device,
"keep_alive_seconds": sidecar.keep_alive_seconds,
"default_model": DEFAULT_STT_MODEL,
"models": list(STT_MODELS.keys()),
"downloaded_models": transformers_downloaded,
"download": stt_sidecar.download_status(),
},
# whisper.cpp (GGUF) engine.
"gguf": {
"available": stt_ggml_sidecar.is_available(),
"loaded_model": ggml.loaded_model,
"loading": ggml.is_loading(),
"device": ggml.device,
"keep_alive_seconds": ggml.keep_alive_seconds,
"default_model": stt_ggml_sidecar.DEFAULT_GGML_STT_MODEL,
"models": list(stt_ggml_sidecar.GGML_STT_MODELS.keys()),
"downloaded_models": [
model_id
for model_id in stt_ggml_sidecar.GGML_STT_MODELS
if stt_ggml_sidecar._cached_model_path(model_id) is not None
],
"download": stt_ggml_sidecar.download_status(),
},
}
)
@studio_router.post("/audio/stt/download")
async def stt_download(
payload: SttLoadRequest,
current_subject: str = Depends(get_current_subject),
hf_token: Optional[str] = Depends(get_hf_token),
):
"""Start a background download of a dictation model.
Both engines download directly (a GGML checkpoint is a single file the Model
Hub's GGUF variant planner cannot express; a Transformers checkpoint is a
whole snapshot). Progress is reported by /audio/stt/status.
"""
from core.inference import stt_ggml_sidecar, stt_sidecar
from core.inference.stt_sidecar import (
SttModelCompatibilityError,
SttModelIdError,
validate_remote_model,
)
engine = _resolve_serving_stt_engine(payload.engine)
module = stt_ggml_sidecar if engine == "gguf" else stt_sidecar
try:
# Transformers accepts custom `owner/model` repos, so confirm the repo is
# a Whisper checkpoint (metadata-only) before snapshot_download pulls a
# possibly-large non-STT repo into the shared cache. Curated ids
# short-circuit; GGUF only accepts curated ids, so it needs no check.
if engine != "gguf":
validated = await asyncio.to_thread(validate_remote_model, payload.model, hf_token)
# Pin the download to the commit that was just validated so the
# repo cannot be swapped between validation and snapshot_download.
await asyncio.to_thread(
module.start_model_download,
payload.model,
hf_token,
validated.get("revision"),
)
else:
await asyncio.to_thread(module.start_model_download, payload.model, hf_token)
except SttModelIdError as e:
raise HTTPException(status_code = 422, detail = str(e))
except SttModelCompatibilityError as e:
raise HTTPException(status_code = 422, detail = str(e))
return JSONResponse(content = module.download_status())
@studio_router.post("/audio/stt/load")
async def stt_load(payload: SttLoadRequest, current_subject: str = Depends(get_current_subject)):
"""Load the selected STT model after the user starts local dictation."""
from core.inference.stt_sidecar import (
SttLoadCancelledError,
SttModelCompatibilityError,
SttModelIdError,
SttModelNotDownloadedError,
SttUnavailableError,
get_stt_sidecar,
)
sidecar = _stt_sidecar_for(_resolve_serving_stt_engine(payload.engine))
try:
await asyncio.to_thread(sidecar.load, payload.model)
except SttModelNotDownloadedError as e:
raise HTTPException(status_code = 409, detail = str(e))
except SttUnavailableError as e:
raise HTTPException(status_code = 501, detail = str(e))
except SttLoadCancelledError as e:
raise HTTPException(status_code = 409, detail = str(e))
except SttModelIdError as e:
raise HTTPException(status_code = 422, detail = str(e))
except SttModelCompatibilityError as e:
raise HTTPException(status_code = 422, detail = str(e))
except Exception as e:
logger.error(f"STT load error: {e}", exc_info = True)
raise HTTPException(status_code = 500, detail = safe_error_detail(e))
return JSONResponse(content = {"loaded_model": sidecar.loaded_model, "device": sidecar.device})
@studio_router.post("/audio/stt/validate")
async def stt_validate(
payload: SttLoadRequest,
current_subject: str = Depends(get_current_subject),
hf_token: Optional[str] = Depends(get_hf_token),
):
"""Verify a Hub repository is a Whisper checkpoint before downloading it."""
from core.inference.stt_sidecar import (
SttModelCompatibilityError,
SttModelIdError,
validate_remote_model,
)
try:
result = await asyncio.to_thread(validate_remote_model, payload.model, hf_token)
except (SttModelIdError, SttModelCompatibilityError) as e:
raise HTTPException(status_code = 422, detail = str(e))
return JSONResponse(content = result)
@studio_router.post("/audio/stt/unload")
async def stt_unload(
engine: Optional[str] = None, current_subject: str = Depends(get_current_subject)
):
"""Release the local STT model when dictation is idle.
Without an engine, both sidecars unload so an engine switch in Voice
settings always frees whichever backend was resident.
"""
if engine is None:
engines = ["transformers", "gguf"]
else:
# Use the serving resolver: a "gguf" pick without whisper-server is
# actually served by the Transformers fallback, so unload must target
# that same engine or the resident model is never freed.
engines = [_resolve_serving_stt_engine(engine)]
# Attempt every engine even if one raises, so failing to unload one never
# skips freeing the other (both can be resident after a switch).
failed: list[str] = []
for name in engines:
try:
await asyncio.to_thread(_stt_sidecar_for(name).unload)
except Exception as exc: # noqa: BLE001 - report after attempting all engines
logger.warning("Failed to unload STT engine '%s': %s", name, exc)
failed.append(name)
if failed:
raise HTTPException(
status_code = 500,
detail = f"Failed to unload STT engine(s): {', '.join(failed)}",
)
return JSONResponse(content = {"loaded_model": None, "device": None})
async def _transcribe_audio_bytes(
raw: bytes,
model: Optional[str],
language: Optional[str],
fast: bool,
engine: Optional[str] = None,
) -> JSONResponse:
"""Run STT for already-decoded request bytes."""
from core.inference.stt_sidecar import (
SttAudioDecodeError,
SttAudioTooLongError,
SttLanguageError,
SttLoadCancelledError,
SttModelCompatibilityError,
SttModelIdError,
SttModelNotDownloadedError,
SttUnavailableError,
)
if not raw:
raise HTTPException(status_code = 400, detail = "Audio is empty.")
if len(raw) > _MAX_AUDIO_RAW_BYTES:
raise HTTPException(status_code = 413, detail = "Audio is too large.")
sidecar = _stt_sidecar_for(_resolve_serving_stt_engine(engine))
try:
result = await asyncio.to_thread(
sidecar.transcribe,
raw,
model,
language,
fast,
)
except SttUnavailableError as e:
raise HTTPException(status_code = 501, detail = str(e))
except SttLoadCancelledError as e:
raise HTTPException(status_code = 409, detail = str(e))
except SttModelNotDownloadedError as e:
raise HTTPException(status_code = 409, detail = str(e))
except SttModelIdError as e:
raise HTTPException(status_code = 422, detail = str(e))
except SttModelCompatibilityError as e:
raise HTTPException(status_code = 422, detail = str(e))
except SttLanguageError as e:
raise HTTPException(status_code = 422, detail = str(e))
except SttAudioTooLongError as e:
raise HTTPException(status_code = 413, detail = str(e))
except SttAudioDecodeError as e:
raise HTTPException(status_code = 400, detail = str(e))
except Exception as e:
logger.error(f"Transcription error: {e}", exc_info = True)
raise HTTPException(status_code = 500, detail = safe_error_detail(e))
return JSONResponse(content = result)
@studio_router.post("/audio/transcribe")
async def transcribe_audio(
payload: TranscribeRequest, current_subject: str = Depends(get_current_subject)
):
"""Transcribe dictation audio to text via the STT sidecar.
Runs alongside the chat model without evicting it, so any model (including
text-only ones) can be driven by voice.
"""
b64 = payload.audio or ""
if not b64:
raise HTTPException(status_code = 400, detail = "No audio provided.")
if len(b64) > _MAX_AUDIO_B64_CHARS:
raise HTTPException(status_code = 413, detail = "Audio is too large.")
try:
raw = base64.b64decode(b64, validate = True)
except Exception:
raise HTTPException(status_code = 400, detail = "Audio is not valid base64.")
return await _transcribe_audio_bytes(
raw, payload.model, payload.language, payload.fast, payload.engine
)
@studio_router.post("/audio/transcribe/raw")
async def transcribe_audio_raw(
request: Request,
model: Optional[str] = None,
language: Optional[str] = None,
fast: bool = False,
engine: Optional[str] = None,
current_subject: str = Depends(get_current_subject),
):
"""Transcribe a raw audio body without base64 or JSON conversion overhead."""
chunks: list[bytes] = []
size = 0
async for chunk in request.stream():
size += len(chunk)
if size > _MAX_AUDIO_RAW_BYTES:
raise HTTPException(status_code = 413, detail = "Audio is too large.")
chunks.append(chunk)
return await _transcribe_audio_bytes(b"".join(chunks), model, language, fast, engine)
# =====================================================================
# OpenAI-Compatible Chat Completions (/chat/completions)
# =====================================================================
@ -6162,8 +6502,8 @@ def _decode_audio_base64(b64: str) -> np.ndarray:
# cap the encoded length to bound the upload. _MAX_AUDIO_SECONDS additionally
# bounds the *decoded* length, since a small compressed file (opus/flac/etc.)
# can expand to a far larger PCM array than the encoded-size cap implies.
_MAX_AUDIO_RAW_BYTES = 25 * 1024 * 1024
_MAX_AUDIO_B64_CHARS = _MAX_AUDIO_RAW_BYTES * 4 // 3
_MAX_AUDIO_RAW_BYTES = STT_AUDIO_RAW_MAX_BYTES
_MAX_AUDIO_B64_CHARS = STT_AUDIO_B64_MAX_CHARS
_MAX_AUDIO_SECONDS = 30 * 60
_WAV_HEADER_BYTES = 44
_MIN_TRANSCODE_AUDIO_SAMPLE_RATE = 8000

View file

@ -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
"""llama.cpp prebuilt update endpoints.
"""llama.cpp prebuilt update endpoints -- the single main update item.
GET /api/llama/update-status -> is a newer prebuilt available + job state
POST /api/llama/update -> download + atomically swap to the latest
@ -9,13 +9,19 @@ POST /api/llama/update -> download + atomically swap to the latest
Detection reuses utils.llama_cpp_freshness; the swap reuses
install_llama_prebuilt.py via utils.llama_cpp_update. Both fail open so the UI
never blocks on a missing marker / offline GitHub.
whisper.cpp updates piggyback here: the status payload carries a whisper
sub-status (update_available is the llama OR whisper union) and the apply job
chains a whisper phase after the llama phase when whisper is behind, with a
per-phase breakdown in job.phases. All pre-existing top-level fields keep
their shape, so older clients keep working unchanged.
"""
from __future__ import annotations
import asyncio
import threading
from typing import Optional
from typing import Literal, Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
@ -38,6 +44,31 @@ class LlamaUpdateJob(BaseModel):
progress: Optional[float] = Field(None, description = "0..1 while running, 1 on success.")
started_at: Optional[str] = None
finished_at: Optional[str] = None
phases: Optional[dict] = Field(
None,
description = (
"Per-phase breakdown of a chained llama+whisper job "
"(name -> state/progress/to_tag/...); None for pre-chaining jobs."
),
)
class WhisperSubStatus(BaseModel):
"""The whisper piggyback inside the llama update item."""
update_available: bool = Field(
False, description = "True when the chained apply would run a whisper phase."
)
installed_tag: Optional[str] = None
latest_tag: Optional[str] = None
update_size_bytes: Optional[int] = None
skip_reason: Optional[str] = Field(
None,
description = (
"Why the whisper phase would be skipped "
"(up_to_date | local_link | source_build | not_installed | ...)."
),
)
class LlamaUpdateStatusResponse(BaseModel):
@ -46,7 +77,18 @@ class LlamaUpdateStatusResponse(BaseModel):
description = "True when the install came from an Unsloth prebuilt (has a marker).",
)
update_available: bool = Field(
False, description = "True when the latest release is genuinely newer than the install."
False,
description = (
"True when an update would do something: llama.cpp is behind OR the "
"whisper piggyback is behind."
),
)
llama_update_available: bool = Field(
False, description = "True when the latest llama.cpp release is newer than the install."
)
update_component: Optional[Literal["llama", "whisper"]] = Field(
None,
description = "Component whose versions the combined update banner should display.",
)
stale: bool = Field(
False, description = "Update available AND install older than the staleness threshold."
@ -62,6 +104,9 @@ class LlamaUpdateStatusResponse(BaseModel):
update_size_bytes: Optional[int] = Field(
None, description = "Download size of the prebuilt Update would fetch, in bytes."
)
whisper: Optional[WhisperSubStatus] = Field(
None, description = "Whisper piggyback sub-status; None when the probe is unavailable."
)
job: LlamaUpdateJob = Field(default_factory = LlamaUpdateJob)

View file

@ -224,11 +224,8 @@ def derive_model_type(
def _resolve_hf_cache_dir() -> Path:
"""Resolve local HF cache root used by hub downloads."""
try:
from huggingface_hub.constants import HF_HUB_CACHE
return Path(HF_HUB_CACHE)
except Exception:
return Path.home() / ".cache" / "huggingface" / "hub"
from utils.hf_cache_settings import get_hf_cache_paths
return get_hf_cache_paths().hub_cache
def _is_model_directory(d: Path) -> bool:
@ -370,10 +367,12 @@ def _scan_models_dir(models_dir: Path, *, limit: int | None = None) -> List[Loca
return found
def _scan_hf_cache(cache_dir: Path) -> List[LocalModelInfo]:
def _scan_hf_cache(cache_dir: Path, *, active_cache: bool = True) -> List[LocalModelInfo]:
if not cache_dir.exists() or not cache_dir.is_dir():
return []
from hub.utils import inventory_scan as hf_cache_scan
found: List[LocalModelInfo] = []
for repo_dir in cache_dir.glob("models--*"):
if not repo_dir.is_dir():
@ -389,13 +388,21 @@ def _scan_hf_cache(cache_dir: Path) -> List[LocalModelInfo]:
except OSError:
updated_at = None
partial = hf_cache_scan.is_snapshot_partial("model", model_id, repo_dir)
partial = partial or hf_cache_scan.is_gguf_repo_partial(model_id, repo_dir)
load_id = model_id
if not active_cache:
load_id = _resolve_hf_cache_realpath(repo_dir) or str(repo_dir.resolve())
found.append(
LocalModelInfo(
id = model_id,
id = load_id,
model_id = model_id,
display_name = model_id.split("/")[-1],
path = str(repo_dir),
path = load_id if not active_cache else str(repo_dir),
source = "hf_cache",
active_cache = active_cache,
partial = partial,
updated_at = updated_at,
),
)
@ -776,26 +783,34 @@ def collect_local_models(models_root: Path) -> List[LocalModelInfo]:
legacy_hf_cache_dir,
lmstudio_model_dirs,
)
from utils.hf_cache_settings import known_hf_hub_caches
hf_cache_dir = _resolve_hf_cache_dir()
legacy_hf = legacy_hf_cache_dir()
hf_default = hf_default_cache_dir()
lm_dirs = lmstudio_model_dirs()
local_models = _scan_models_dir(models_root) + _scan_hf_cache(hf_cache_dir)
# Resolve once; an inaccessible aux cache must skip that scan, not 500.
hf_cache_real = _safe_resolve(hf_cache_dir)
legacy_real = _safe_resolve(legacy_hf)
default_real = _safe_resolve(hf_default)
# Scan legacy Unsloth HF cache for backward compatibility.
if _safe_is_dir(legacy_hf) and legacy_real != hf_cache_real:
local_models += _scan_hf_cache(legacy_hf)
# Scan HF system default cache (may differ under env overrides).
if _safe_is_dir(hf_default) and default_real != hf_cache_real and default_real != legacy_real:
local_models += _scan_hf_cache(hf_default)
local_models = _scan_models_dir(models_root)
active_cache_real = _safe_resolve(hf_cache_dir)
active_cache_key = os.path.normcase(active_cache_real) if active_cache_real else None
seen_hf: set[str] = set()
for cache_dir in (
hf_cache_dir,
*known_hf_hub_caches(),
legacy_hf,
hf_default,
):
cache_real = _safe_resolve(cache_dir)
if cache_real is None:
continue
cache_key = os.path.normcase(str(cache_real))
if cache_key in seen_hf:
continue
seen_hf.add(cache_key)
local_models += _scan_hf_cache(
cache_dir,
active_cache = cache_key == active_cache_key,
)
# Scan LM Studio directories.
for lm_dir in lm_dirs:
@ -817,7 +832,7 @@ def collect_local_models(models_root: Path) -> List[LocalModelInfo]:
m
for m in (
_scan_models_dir(folder_path, limit = _MAX_MODELS_PER_FOLDER)
+ _scan_hf_cache(folder_path)
+ _scan_hf_cache(folder_path, active_cache = False)
+ _scan_lmstudio_dir(folder_path)
)
if not any(p in (".studio_links", "ollama_links") for p in Path(m.path).parts)
@ -838,8 +853,18 @@ def collect_local_models(models_root: Path) -> List[LocalModelInfo]:
# even when the model is also in the HF cache.
deduped: dict[str, LocalModelInfo] = {}
for model in local_models:
key = f"{model.id}\x00custom" if model.source == "custom" else model.id
if key not in deduped:
semantic_id = model.model_id if model.source == "hf_cache" and model.model_id else model.id
key = f"{semantic_id}\x00custom" if model.source == "custom" else semantic_id
existing = deduped.get(key)
prefer_model = existing is None
if existing is not None and model.source == existing.source == "hf_cache":
if model.partial != existing.partial:
prefer_model = not model.partial
elif bool(model.active_cache) != bool(existing.active_cache):
prefer_model = bool(model.active_cache)
else:
prefer_model = (model.updated_at or 0) > (existing.updated_at or 0)
if prefer_model:
deduped[key] = model
models = sorted(
@ -1202,10 +1227,7 @@ def _build_browse_allowlist(
legacy_hf_cache_dir,
well_known_model_dirs,
)
from utils.paths.external_media import (
linux_run_media_mount_roots,
windows_drive_roots,
)
from utils.paths import external_media
from storage.studio_db import list_scan_folders
candidates: list[Path] = []
@ -1222,9 +1244,12 @@ def _build_browse_allowlist(
_add(Path.home())
if media_roots is None:
media_roots = linux_run_media_mount_roots()
media_roots = [
*external_media.linux_run_media_mount_roots(),
*external_media.macos_volume_roots(),
]
if drive_roots is None:
drive_roots = windows_drive_roots()
drive_roots = external_media.windows_drive_roots()
for p in media_roots:
_add(p)
for p in drive_roots:
@ -1502,10 +1527,7 @@ def browse_folders(
then hidden (if ``show_hidden=true``).
"""
from utils.paths import hf_default_cache_dir, well_known_model_dirs
from utils.paths.external_media import (
linux_run_media_mount_roots,
windows_drive_roots,
)
from utils.paths import external_media
from storage.studio_db import (
contains_sensitive_path_component,
is_denied_system_path,
@ -1514,8 +1536,11 @@ def browse_folders(
# Probe removable-media and Windows drive roots once; the allowlist and
# chips reuse the result so a disconnected mapped drive isn't scanned twice.
media_roots = linux_run_media_mount_roots()
drive_roots = windows_drive_roots()
media_roots = [
*external_media.linux_run_media_mount_roots(),
*external_media.macos_volume_roots(),
]
drive_roots = external_media.windows_drive_roots()
# Build once; the sandbox check and suggestion chips share it.
allowed_roots = _build_browse_allowlist(media_roots, drive_roots)
@ -2034,19 +2059,19 @@ async def discard_remote_code_download(
# Never delete a model that is loaded for inference.
try:
from hub.services.models.deletion import _loaded_id_matches_repo
from routes.inference import get_llama_cpp_backend
llama_backend = get_llama_cpp_backend()
if llama_backend.is_loaded and llama_backend.model_identifier:
loaded = llama_backend.model_identifier.lower()
if loaded == model_name.lower() or loaded.startswith(model_name.lower()):
if _loaded_id_matches_repo(llama_backend.model_identifier, model_name):
return {"deleted": False, "reason": "loaded"}
except Exception:
pass
try:
inference_backend = get_inference_backend()
if inference_backend.active_model_name:
active = inference_backend.active_model_name.lower()
if active == model_name.lower() or active.startswith(model_name.lower()):
if _loaded_id_matches_repo(inference_backend.active_model_name, model_name):
return {"deleted": False, "reason": "loaded"}
except Exception:
pass
@ -2588,13 +2613,10 @@ def _read_native_context_length(repo_id: str, is_local: bool) -> Optional[int]:
if is_local:
roots = [Path(repo_id)]
else:
from huggingface_hub import constants as hf_constants
from hub.utils.hf_cache_state import iter_repo_cache_dirs
if not _is_valid_repo_id(repo_id):
return None
cache_dir = Path(hf_constants.HF_HUB_CACHE)
target = f"models--{repo_id.replace('/', '--')}".lower()
roots = [e for e in cache_dir.iterdir() if e.name.lower() == target]
roots = list(iter_repo_cache_dirs("model", repo_id))
for root in roots:
for f in _iter_gguf_paths(root):
@ -2623,18 +2645,15 @@ def _resolve_quant_gguf(repo_id: str, quant: str, is_local: bool) -> tuple[Optio
if is_local:
roots = [Path(repo_id)]
else:
from huggingface_hub import constants as hf_constants
from hub.utils.hf_cache_state import iter_repo_cache_dirs
if not _is_valid_repo_id(repo_id):
return None, 0
cache_dir = Path(hf_constants.HF_HUB_CACHE)
target = f"models--{repo_id.replace('/', '--')}".lower()
roots = []
for entry in cache_dir.iterdir():
if entry.name.lower() == target:
snaps = entry / "snapshots"
if snaps.is_dir():
roots.extend(s for s in snaps.iterdir() if s.is_dir())
for entry in iter_repo_cache_dirs("model", repo_id):
snaps = entry / "snapshots"
if snaps.is_dir():
roots.extend(s for s in snaps.iterdir() if s.is_dir())
want = _normalized_quant_label(quant)
best_total = 0
@ -2734,6 +2753,8 @@ async def get_gguf_variants(
repo_id: str = Query(
..., description = "HuggingFace repo ID (e.g. 'unsloth/gemma-3-4b-it-GGUF')"
),
prefer_local_cache: bool = False,
local_path: Optional[str] = None,
hf_token: Optional[str] = Query(None, description = "HuggingFace token for private repos"),
hf_token_header: Optional[str] = Depends(get_hf_token),
current_subject: str = Depends(get_current_subject),
@ -2745,9 +2766,16 @@ async def get_gguf_variants(
response = await hub_gguf_variants.get_gguf_variants_response(
repo_id,
prefer_local_cache = prefer_local_cache,
local_path = local_path,
hf_token = hf_token,
)
local = is_local_path(repo_id)
context_model = (
local_path
if prefer_local_cache and local_path and is_local_path(local_path)
else repo_id
)
local = is_local_path(context_model)
return GgufVariantsResponse(
repo_id = response.repo_id,
@ -2769,7 +2797,7 @@ async def get_gguf_variants(
# The header walk reads tokenizer arrays on dense models (tens of
# ms per uncached file); keep it off the event loop.
context_length = await asyncio.to_thread(
_read_native_context_length, repo_id, is_local = local
_read_native_context_length, context_model, is_local = local
),
)
except HTTPException:
@ -2787,69 +2815,17 @@ async def get_gguf_download_progress(
repo_id: str = Query(..., description = "HuggingFace repo ID"),
variant: str = Query("", description = "Quantization variant (e.g. UD-TQ1_0)"),
expected_bytes: int = Query(0, description = "Expected total download size in bytes"),
hf_token: Optional[str] = Depends(get_hf_token),
current_subject: str = Depends(get_current_subject),
):
"""Download progress from cached GGUF files for a specific variant.
Tracks completed shards in snapshots and in-progress (.incomplete)
downloads in the blobs directory.
"""
try:
if not _is_valid_repo_id(repo_id):
return {
"downloaded_bytes": 0,
"expected_bytes": expected_bytes,
"progress": 0,
}
from huggingface_hub import constants as hf_constants
cache_dir = Path(hf_constants.HF_HUB_CACHE)
target = f"models--{repo_id.replace('/', '--')}".lower()
variant_lower = variant.lower().replace("-", "").replace("_", "")
downloaded_bytes = 0
in_progress_bytes = 0
for entry in cache_dir.iterdir():
if entry.name.lower() == target:
# Completed .gguf files for this variant in snapshots.
# Exclude mmproj so a vision adapter can't satisfy a same-label
# main variant (e.g. mmproj-F16 vs an F16 weight).
for f in _iter_gguf_paths(entry):
if _is_mmproj_filename(f.name):
continue
rel = f.relative_to(entry).as_posix()
quant = _extract_quant_label(rel)
if _is_big_endian_gguf_path(rel, quant):
continue
rel_key = rel.lower().replace("-", "").replace("_", "")
if not variant_lower or variant_lower in rel_key:
try:
downloaded_bytes += f.stat().st_size
except OSError:
continue # broken symlink / unreadable: skip
# In-progress (.incomplete) downloads in blobs.
blobs_dir = entry / "blobs"
if blobs_dir.is_dir():
for f in blobs_dir.iterdir():
if f.is_file() and f.name.endswith(".incomplete"):
try:
in_progress_bytes += f.stat().st_size
except OSError:
continue
break
total_progress_bytes = downloaded_bytes + in_progress_bytes
progress = min(total_progress_bytes / expected_bytes, 0.99) if expected_bytes > 0 else 0
# Report 1.0 only when all bytes are in completed files.
if expected_bytes > 0 and downloaded_bytes >= expected_bytes:
progress = 1.0
return {
"downloaded_bytes": total_progress_bytes,
"expected_bytes": expected_bytes,
"progress": round(progress, 3),
}
except Exception:
return {"downloaded_bytes": 0, "expected_bytes": expected_bytes, "progress": 0}
"""Compatibility route backed by the shared multi-cache progress service."""
from hub.services.models import downloads
return await downloads.get_gguf_download_progress_response(
repo_id,
variant = variant,
expected_bytes = expected_bytes,
hf_token = hf_token,
)
def _resolve_hf_cache_realpath(repo_dir: Path) -> Optional[str]:
@ -2874,98 +2850,12 @@ def _resolve_hf_cache_realpath(repo_dir: Path) -> Optional[str]:
@router.get("/download-progress")
async def get_download_progress(
repo_id: str = Query(..., description = "HuggingFace repo ID"),
hf_token: Optional[str] = Depends(get_hf_token),
current_subject: str = Depends(get_current_subject),
):
"""Return download progress for any HuggingFace model repo.
Checks the local HF cache for completed blobs and in-progress
(.incomplete) downloads. Gets the expected total size from the HF API
on the first call, then caches it for later polls. Also returns
``cache_path``: the realpath of the snapshot dir (or cache repo root
if no snapshot yet) so the UI can show where weights live on disk.
"""
_empty = {
"downloaded_bytes": 0,
"expected_bytes": 0,
"progress": 0,
"cache_path": None,
}
try:
if not _is_valid_repo_id(repo_id):
return _empty
from huggingface_hub import constants as hf_constants
cache_dir = Path(hf_constants.HF_HUB_CACHE)
target = f"models--{repo_id.replace('/', '--')}".lower()
completed_bytes = 0
in_progress_bytes = 0
cache_path: Optional[str] = None
for entry in cache_dir.iterdir():
if entry.name.lower() != target:
continue
cache_path = _resolve_hf_cache_realpath(entry)
blobs_dir = entry / "blobs"
if not blobs_dir.is_dir():
break
for f in blobs_dir.iterdir():
if not f.is_file():
continue
if f.name.endswith(".incomplete"):
in_progress_bytes += f.stat().st_size
else:
completed_bytes += f.stat().st_size
break
downloaded_bytes = completed_bytes + in_progress_bytes
if downloaded_bytes == 0:
return {**_empty, "cache_path": cache_path}
expected_bytes = _get_repo_size_cached(repo_id)
if expected_bytes <= 0:
# Total unknown; report bytes only, no percentage.
return {
"downloaded_bytes": downloaded_bytes,
"expected_bytes": 0,
"progress": 0,
"cache_path": cache_path,
}
# 95% threshold (blob dedup can skew completed_bytes). Do NOT
# treat "no .incomplete files" as done: HF downloads sequentially,
# so none exist between files even when far from finished.
if completed_bytes >= expected_bytes * 0.95:
progress = 1.0
else:
progress = min(downloaded_bytes / expected_bytes, 0.99)
return {
"downloaded_bytes": downloaded_bytes,
"expected_bytes": expected_bytes,
"progress": round(progress, 3),
"cache_path": cache_path,
}
except Exception as e:
logger.warning(f"Error checking download progress for {repo_id}: {e}")
return _empty
_repo_size_cache: dict[str, int] = {}
def _get_repo_size_cached(repo_id: str) -> int:
if repo_id in _repo_size_cache:
return _repo_size_cache[repo_id]
try:
from huggingface_hub import model_info as hf_model_info
info = hf_model_info(repo_id, token = None, files_metadata = True)
total = sum(s.size for s in info.siblings if s.size)
_repo_size_cache[repo_id] = total
return total
except Exception as e:
logger.warning(f"Failed to get repo size for {repo_id}: {e}")
return 0
"""Compatibility route backed by the shared multi-cache progress service."""
from hub.services.models import downloads
return await downloads.get_download_progress_response(repo_id, hf_token = hf_token)
def _repo_in_any_hf_cache(model_name: str) -> bool:
@ -2978,25 +2868,13 @@ def _repo_in_any_hf_cache(model_name: str) -> bool:
would delete a model they did not download via the scan. Mirrors the cache set in
``_all_hf_cache_scans`` but only probes for the one repo dir (cheap, no full scan).
"""
from utils.paths import (
hf_default_cache_dir,
legacy_hf_cache_dir,
resolve_cached_repo_id_case,
)
from utils.paths import resolve_cached_repo_id_case
dirname = f"models--{resolve_cached_repo_id_case(model_name).replace('/', '--')}"
dirname_lower = dirname.lower()
candidates = []
try:
from huggingface_hub.constants import HF_HUB_CACHE
candidates.append(Path(HF_HUB_CACHE))
except Exception:
pass
for fn in (legacy_hf_cache_dir, hf_default_cache_dir):
try:
candidates.append(fn())
except Exception:
continue
from hub.utils.hf_cache_state import hf_cache_roots
candidates = hf_cache_roots()
# resolve_cached_repo_id_case only normalizes the ACTIVE cache, but discard deletes
# case-insensitively across all caches, so detect case-insensitively too -- else a
# pre-existing case-variant repo is misreported as scan-created and deleted on decline.
@ -3020,38 +2898,8 @@ def _all_hf_cache_scans():
broken symlink, OS-redirected ~/.cache) is skipped, not fatal, so the
Downloaded list never blanks out and downloads never leak into Recommended.
"""
from huggingface_hub import scan_cache_dir
from utils.paths import legacy_hf_cache_dir, hf_default_cache_dir
scans = []
# Guard the active cache too: degrade to "no downloads" instead of raising.
try:
scans.append(scan_cache_dir())
except Exception as exc:
logger.warning("Could not scan active HF cache: %s", exc)
seen: set[str] = set()
try:
# Resolve the active cache dir for dedup.
from huggingface_hub.constants import HF_HUB_CACHE
seen.add(str(Path(HF_HUB_CACHE).resolve()))
except Exception:
pass
for extra_fn in (legacy_hf_cache_dir, hf_default_cache_dir):
try:
extra = extra_fn()
# is_dir()/resolve() can raise on an inaccessible path; skip it.
if not extra.is_dir():
continue
resolved = str(extra.resolve())
if resolved in seen:
continue
seen.add(resolved)
scans.append(scan_cache_dir(cache_dir = str(extra)))
except Exception as exc:
logger.warning("Could not scan HF cache %s: %s", extra_fn.__name__, exc)
return scans
from hub.utils.inventory_scan import all_hf_cache_scans
return all_hf_cache_scans()
def _is_gguf_filename(name: str) -> bool:
@ -3178,7 +3026,9 @@ async def list_cached_gguf(current_subject: str = Depends(get_current_subject)):
if repo_info.repo_type != "model":
continue
repo_id = repo_info.repo_id
if _is_hidden_model(repo_id):
# Pass the snapshot path too so the config check also hides
# custom Whisper checkpoints, not just curated repo ids.
if _is_hidden_model(repo_id, str(repo_info.repo_path)):
continue
total_size = _repo_gguf_size_bytes(repo_info)
if total_size == 0:
@ -3235,7 +3085,9 @@ async def list_cached_models(
if repo_info.repo_type != "model":
continue
repo_id = repo_info.repo_id
if _is_hidden_model(repo_id):
# Pass the snapshot path too so the config check also hides
# custom Whisper checkpoints, not just curated repo ids.
if _is_hidden_model(repo_id, str(repo_info.repo_path)):
continue
if _repo_has_gguf_files(repo_info):
continue
@ -3293,124 +3145,13 @@ async def list_cached_models(
async def delete_cached_model(
repo_id: str = Body(...),
variant: Optional[str] = Body(None),
cache_path: Optional[str] = Body(None),
hf_token: Optional[str] = Depends(get_hf_token),
current_subject: str = Depends(get_current_subject),
):
"""Delete a cached model repo (or a specific GGUF variant) from the HF cache.
With *variant*, only GGUF files matching that quant label are removed
(e.g. ``UD-Q4_K_XL``); otherwise the whole repo is deleted. Refuses
if the model is currently loaded for inference.
"""
if not _is_valid_repo_id(repo_id):
raise HTTPException(status_code = 400, detail = "Invalid repo_id format")
# Refuse if the model is currently loaded.
try:
from routes.inference import get_llama_cpp_backend
llama_backend = get_llama_cpp_backend()
if llama_backend.is_loaded and llama_backend.model_identifier:
loaded_id = llama_backend.model_identifier.lower()
if loaded_id == repo_id.lower() or loaded_id.startswith(repo_id.lower()):
raise HTTPException(
status_code = 400,
detail = "Unload the model before deleting",
)
except HTTPException:
raise
except Exception:
pass
try:
inference_backend = get_inference_backend()
if inference_backend.active_model_name:
active = inference_backend.active_model_name.lower()
if active == repo_id.lower() or active.startswith(repo_id.lower()):
raise HTTPException(
status_code = 400,
detail = "Unload the model before deleting",
)
except HTTPException:
raise
except Exception:
pass
try:
cache_scans = _all_hf_cache_scans()
target_repo = None
for hf_cache in cache_scans:
for repo_info in hf_cache.repos:
if repo_info.repo_type != "model":
continue
if repo_info.repo_id.lower() == repo_id.lower():
target_repo = repo_info
break
if target_repo is not None:
break
if target_repo is None:
raise HTTPException(status_code = 404, detail = "Model not found in cache")
# ── Per-variant GGUF deletion ────────────────────────────
if variant:
deleted_bytes = 0
deleted_count = 0
for rev in target_repo.revisions:
for f in rev.files:
if not _is_gguf_filename(f.file_name):
continue
quant = _extract_quant_label(f.file_name)
if quant.lower() != variant.lower():
continue
# Delete the blob (data) and the snapshot symlink.
try:
blob = Path(f.blob_path)
snap = Path(f.file_path)
size = blob.stat().st_size if blob.exists() else 0
if snap.exists() or snap.is_symlink():
snap.unlink()
if blob.exists():
blob.unlink()
deleted_bytes += size
deleted_count += 1
except Exception as e:
logger.warning(f"Failed to delete {f.file_name}: {e}")
if deleted_count == 0:
raise HTTPException(
status_code = 404,
detail = f"Variant {variant} not found in cache for {repo_id}",
)
freed_mb = deleted_bytes / (1024 * 1024)
logger.info(
f"Deleted {deleted_count} file(s) for {repo_id} variant {variant}: "
f"{freed_mb:.1f} MB freed"
)
return {"status": "deleted", "repo_id": repo_id, "variant": variant}
# ── Full repo deletion ───────────────────────────────────
revision_hashes = [rev.commit_hash for rev in target_repo.revisions]
if not revision_hashes:
raise HTTPException(status_code = 404, detail = "No revisions found for model")
delete_strategy = hf_cache.delete_revisions(*revision_hashes)
logger.info(
f"Deleting cached model {repo_id}: "
f"{delete_strategy.expected_freed_size_str} will be freed"
)
delete_strategy.execute()
return {"status": "deleted", "repo_id": repo_id}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting cached model {repo_id}: {e}", exc_info = True)
raise HTTPException(
status_code = 500,
detail = "Failed to delete cached model",
)
"""Compatibility route backed by the shared multi-cache deletion service."""
from hub.services.models import deletion
return await deletion.delete_cached_model_response(repo_id, variant, hf_token, cache_path)
def _resolve_cached_model_path(repo_id: str, variant: Optional[str]) -> Path:

View file

@ -60,6 +60,7 @@ from utils.embedding_model_settings import (
set_rag_embedding_model,
validate_embedding_model,
)
from utils.hf_cache_settings import cache_status, get_hf_cache_paths, set_hf_cache_home
router = APIRouter()
@ -89,6 +90,23 @@ class HelperPrecacheResponse(BaseModel):
disabled_by_env: bool
class HuggingFaceCachePayload(BaseModel):
cache_home: Optional[str] = Field(default = None, max_length = 4096)
class HuggingFaceCacheResponse(BaseModel):
cache_home: str
hub_cache: str
xet_cache: str
source: Literal["default", "studio", "environment"]
editable: bool
is_custom: bool
available: bool
writable: bool
free_bytes: Optional[int] = None
environment_variable: Optional[str] = None
class OpenAIAutoSwitchPayload(BaseModel):
enabled: bool
# None leaves the stored value untouched (partial updates can't clobber it).
@ -135,6 +153,30 @@ def _helper_precache_response(enabled: bool | None = None) -> HelperPrecacheResp
)
def _hugging_face_cache_response() -> HuggingFaceCacheResponse:
return HuggingFaceCacheResponse(**cache_status(get_hf_cache_paths()))
@router.get("/hugging-face-cache", response_model = HuggingFaceCacheResponse)
def get_hugging_face_cache(
current_subject: str = Depends(get_current_subject),
) -> HuggingFaceCacheResponse:
return _hugging_face_cache_response()
@router.put("/hugging-face-cache", response_model = HuggingFaceCacheResponse)
def update_hugging_face_cache(
payload: HuggingFaceCachePayload, current_subject: str = Depends(get_current_subject)
) -> HuggingFaceCacheResponse:
try:
set_hf_cache_home(payload.cache_home)
except RuntimeError as exc:
raise HTTPException(status_code = 409, detail = str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code = 400, detail = str(exc)) from exc
return _hugging_face_cache_response()
@router.get("/upload-limit", response_model = UploadLimitResponse)
def get_upload_limit(current_subject: str = Depends(get_current_subject)) -> UploadLimitResponse:
return _upload_limit_response(get_upload_limit_mb())

View file

@ -415,46 +415,29 @@ async def start_training(
try:
from routes.training_vram import (
can_keep_chat_during_training,
free_chat_models_for_training,
summarize_resident_chat,
coordinate_models_for_training,
)
resident = summarize_resident_chat()
if not resident["any"]:
return
if resident.get("loading"):
# In-flight load can't be sized -> free rather than risk OOM.
freed = free_chat_models_for_training(reason = "chat model still loading")
logger.info("Freed in-flight chat load for training: %s", freed)
return
keep, info = can_keep_chat_during_training(
model_name = training_kwargs["model_name"],
hf_token = training_kwargs["hf_token"],
training_type = training_kwargs["training_type"],
load_in_4bit = training_kwargs["load_in_4bit"],
batch_size = training_kwargs["batch_size"],
max_seq_length = training_kwargs["max_seq_length"],
lora_rank = training_kwargs["lora_r"],
target_modules = training_kwargs["target_modules"],
gradient_checkpointing = training_kwargs["gradient_checkpointing"],
optimizer = training_kwargs["optim"],
gpu_ids = training_kwargs["gpu_ids"],
)
if keep:
logger.info(
"Keeping chat model(s) loaded during training "
"(free ~%s GB, needs ~%s GB): %s",
info.get("usable_gb"),
info.get("required_gb"),
resident,
def _can_keep_resident_models():
return can_keep_chat_during_training(
model_name = training_kwargs["model_name"],
hf_token = training_kwargs["hf_token"],
training_type = training_kwargs["training_type"],
load_in_4bit = training_kwargs["load_in_4bit"],
batch_size = training_kwargs["batch_size"],
max_seq_length = training_kwargs["max_seq_length"],
lora_rank = training_kwargs["lora_r"],
target_modules = training_kwargs["target_modules"],
gradient_checkpointing = training_kwargs["gradient_checkpointing"],
optimizer = training_kwargs["optim"],
gpu_ids = training_kwargs["gpu_ids"],
)
else:
freed = free_chat_models_for_training(
reason = "insufficient VRAM to run training alongside chat",
)
logger.info("Freed chat model(s) for training: %s", freed)
freed = coordinate_models_for_training(_can_keep_resident_models)
if freed:
logger.info("Freed models for training: %s", freed)
except Exception as e:
logger.warning("Chat/training VRAM coordination failed; proceeding: %s", e)
logger.warning("Inference/training memory coordination failed; proceeding: %s", e)
# The hook runs only once start guards pass -> VRAM freed iff training starts.
from utils.transformers_version import SidecarSwapInProgress

View file

@ -1,15 +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
"""VRAM coordination between chat/inference and training.
"""Memory coordination between inference and training.
Decides, from live free VRAM, whether a resident chat model can stay loaded
during training or must be unloaded, and unloads it across all backends
(HF/MLX orchestrator + llama.cpp GGUF server). In the route layer because the
GGUF accessor lives in routes/inference.py; backends are imported lazily.
Uses live free VRAM to keep resident chat and STT models when they fit. STT is
evicted before chat when training needs memory.
"""
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Callable, Dict, List, Optional, Tuple
from loggers import get_logger
@ -77,6 +75,37 @@ def summarize_resident_chat() -> Dict[str, Any]:
}
def summarize_resident_stt() -> Dict[str, Any]:
"""Report the resident dictation model (either engine). Never raises."""
try:
from core.inference.stt_ggml_sidecar import get_ggml_stt_sidecar
from core.inference.stt_sidecar import get_stt_sidecar
sidecar = get_stt_sidecar()
model = sidecar.loaded_model
device = sidecar.device
loading = sidecar.is_loading()
# whisper.cpp holds GPU memory via its subprocess, and both engines can be
# live at once (engine switch or direct /audio/stt/load). Always fold the
# GGUF sidecar in: a resident Transformers model must not mask a GGUF
# server still binding its backend, or admission lets training launch into
# that startup and OOM.
ggml = get_ggml_stt_sidecar()
if not model:
model = ggml.loaded_model
device = device or ggml.device
loading = loading or ggml.is_loading()
return {
"model": model,
"device": device,
"loading": loading,
"any": bool(model or loading),
}
except Exception as e:
logger.warning("Could not inspect STT sidecar: %s", e)
return {"model": None, "device": None, "loading": False, "any": False}
def can_keep_chat_during_training(
*,
model_name: str,
@ -366,3 +395,110 @@ def free_chat_models_for_training(reason: str) -> List[str]:
logger.warning("Could not unload GGUF chat model: %s", e)
return freed
def free_stt_model_for_training(reason: str) -> List[str]:
"""Unload the dictation model(s) before training. Never raises.
The Transformers and GGUF sidecars are freed under independent exception
boundaries so a failure unloading one backend never skips freeing the other
(both can hold accelerator memory at once after an engine switch).
"""
freed: List[str] = []
try:
from core.inference.stt_sidecar import get_stt_sidecar
sidecar = get_stt_sidecar()
if sidecar.is_loading() and sidecar.cancel_pending_load():
logger.info("Cancelling STT model load for training (%s)", reason)
# The loader may still be in from_pretrained()/.to(device) holding
# VRAM; wait for it to observe the cancel and release first.
sidecar.wait_for_load_to_settle()
# A load that finished before seeing the cancel leaves a resident
# model; unload it so training gets the memory back.
if sidecar.loaded_model:
sidecar.unload()
freed.append("stt:loading")
else:
model = sidecar.loaded_model
if model:
logger.info("Unloading STT model '%s' for training (%s)", model, reason)
sidecar.unload()
freed.append(f"stt:{model}")
except Exception as e:
logger.warning("Could not unload Transformers STT model: %s", e)
# Check the GGUF sidecar even after a cancelled/failed Transformers unload;
# both engines can hold memory at once (engine switch or direct load).
try:
from core.inference.stt_ggml_sidecar import get_ggml_stt_sidecar
ggml = get_ggml_stt_sidecar()
if ggml.is_loading() and ggml.cancel_pending_load():
logger.info("Cancelling GGUF STT model load for training (%s)", reason)
# whisper-server may still be binding its backend; wait for the
# cancelled startup to be killed and reaped before training claims
# the memory (loaded_model stays unset until it is ready).
ggml.wait_for_load_to_settle()
if ggml.loaded_model:
ggml.unload()
freed.append("stt:gguf-loading")
else:
ggml_model = ggml.loaded_model
if ggml_model:
logger.info("Unloading GGUF STT model '%s' for training (%s)", ggml_model, reason)
ggml.unload()
freed.append(f"stt:{ggml_model}")
except Exception as e:
logger.warning("Could not unload GGUF STT model: %s", e)
return freed
def coordinate_models_for_training(
can_keep: Callable[[], Tuple[bool, Dict[str, Any]]],
) -> List[str]:
"""Keep resident models when they fit, evicting STT before chat."""
resident_chat = summarize_resident_chat()
resident_stt = summarize_resident_stt()
if not resident_chat["any"] and not resident_stt["any"]:
return []
if resident_chat.get("loading"):
freed = free_stt_model_for_training(reason = "chat model still loading")
freed += free_chat_models_for_training(reason = "chat model still loading")
return freed
freed: List[str] = []
if resident_stt.get("loading"):
released_stt = free_stt_model_for_training(reason = "STT model still loading")
freed += released_stt
resident_stt = (
{"model": None, "device": None, "loading": False, "any": False}
if released_stt
else summarize_resident_stt()
)
if not resident_chat["any"] and not resident_stt["any"]:
return freed
keep, info = can_keep()
if keep:
logger.info(
"Keeping resident models loaded during training (free ~%s GB, needs ~%s GB): %s",
info.get("usable_gb"),
info.get("required_gb"),
{"chat": resident_chat, "stt": resident_stt},
)
return freed
if resident_stt["any"]:
freed += free_stt_model_for_training(reason = "insufficient training memory")
if not resident_chat["any"]:
return freed
keep, _info = can_keep()
if keep:
logger.info("Keeping chat model loaded after freeing STT: %s", resident_chat)
return freed
freed += free_chat_models_for_training(
reason = "insufficient VRAM to run training alongside chat",
)
return freed

View file

@ -0,0 +1,74 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""whisper.cpp prebuilt status endpoint.
GET /api/whisper/update-status -> is a newer prebuilt available + job state
Detection reuses utils.whisper_cpp_freshness and fails open so the UI never
blocks on a missing marker / offline GitHub. There is no whisper-only update
trigger: whisper updates piggyback on the single main update item
(POST /api/llama/update chains a whisper phase when whisper is behind).
"""
from __future__ import annotations
import asyncio
from typing import Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from auth.authentication import get_current_subject
from utils.whisper_cpp_update import get_update_status
router = APIRouter()
class WhisperUpdateJob(BaseModel):
state: str = Field("idle", description = "idle | running | success | error")
message: str = ""
from_tag: Optional[str] = None
to_tag: Optional[str] = None
reload_required: Optional[bool] = None
error: Optional[str] = None
progress: Optional[float] = Field(None, description = "0..1 while running, 1 on success.")
started_at: Optional[str] = None
finished_at: Optional[str] = None
class WhisperUpdateStatusResponse(BaseModel):
supported: bool = Field(
False,
description = "True when the install came from an Unsloth prebuilt (has a marker).",
)
update_available: bool = Field(
False, description = "True when the latest release is genuinely newer than the install."
)
stale: bool = Field(
False, description = "Update available AND install older than the staleness threshold."
)
installed_tag: Optional[str] = None
latest_tag: Optional[str] = None
published_repo: Optional[str] = None
installed_at_utc: Optional[str] = None
age_days: Optional[int] = None
source_build: bool = Field(
False, description = "True when there is no marker (source build) but a prebuilt is offered."
)
update_size_bytes: Optional[int] = Field(
None, description = "Download size of the prebuilt an update would fetch, in bytes."
)
job: WhisperUpdateJob = Field(default_factory = WhisperUpdateJob)
@router.get("/update-status", response_model = WhisperUpdateStatusResponse)
async def whisper_update_status(
force_refresh: bool = Query(
False, description = "Bypass the 24h release cache for an explicit check."
),
current_subject: str = Depends(get_current_subject),
) -> WhisperUpdateStatusResponse:
# Off the event loop: detection may probe the host and read GitHub.
status = await asyncio.to_thread(get_update_status, force_refresh = force_refresh)
return WhisperUpdateStatusResponse(**status)

View file

@ -1244,6 +1244,13 @@ def _apply_supplied_password(password_value: "Optional[str]") -> None:
flush = True,
)
sys.exit(1)
if any(ch.isspace() for ch in supplied):
print(
"Error: password cannot contain spaces; not starting.",
file = sys.stderr,
flush = True,
)
sys.exit(1)
if _is_current_password(supplied):
print(
"Error: the new password must differ from the current bootstrap "

View file

@ -66,6 +66,66 @@ def test_iter_gguf_paths_matches_extension_case_insensitively(tmp_path):
assert result == ["Q4_K_M.gguf", "Q8_0.GGUF"]
def test_legacy_hf_scan_uses_snapshot_path_for_inactive_cache(tmp_path):
repo = tmp_path / "models--Org--Model"
snapshot = repo / "snapshots" / "revision"
snapshot.mkdir(parents = True)
[row] = models_route._scan_hf_cache(tmp_path, active_cache = False)
assert row.model_id == "Org/Model"
assert row.id == str(snapshot.resolve())
assert row.path == str(snapshot.resolve())
def test_collect_local_models_scans_previous_cache(monkeypatch, tmp_path):
active = tmp_path / "active"
previous = tmp_path / "previous"
active.mkdir()
snapshot = previous / "models--Org--Previous" / "snapshots" / "revision"
snapshot.mkdir(parents = True)
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
monkeypatch.setattr("utils.paths.legacy_hf_cache_dir", lambda: tmp_path / "legacy")
monkeypatch.setattr("utils.paths.hf_default_cache_dir", lambda: tmp_path / "default")
monkeypatch.setattr("utils.paths.lmstudio_model_dirs", lambda: [])
monkeypatch.setattr("utils.hf_cache_settings.known_hf_hub_caches", lambda: [active, previous])
monkeypatch.setattr("storage.studio_db.list_scan_folders", lambda: [])
rows = models_route.collect_local_models(tmp_path / "models")
previous_row = next(row for row in rows if row.model_id == "Org/Previous")
assert previous_row.id == str(snapshot.resolve())
def test_collect_local_models_prefers_complete_previous_copy(monkeypatch, tmp_path):
active = tmp_path / "active"
previous = tmp_path / "previous"
active_partial = active / "models--Org--Model" / "blobs" / "abc.incomplete"
active_partial.parent.mkdir(parents = True)
active_partial.write_bytes(b"partial")
snapshot = previous / "models--Org--Model" / "snapshots" / "revision"
snapshot.mkdir(parents = True)
(snapshot / "model.safetensors").write_bytes(b"complete")
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
monkeypatch.setattr("utils.paths.legacy_hf_cache_dir", lambda: tmp_path / "legacy")
monkeypatch.setattr("utils.paths.hf_default_cache_dir", lambda: tmp_path / "default")
monkeypatch.setattr("utils.paths.lmstudio_model_dirs", lambda: [])
monkeypatch.setattr(
"utils.hf_cache_settings.known_hf_hub_caches",
lambda: [active, previous],
)
monkeypatch.setattr("storage.studio_db.list_scan_folders", lambda: [])
rows = models_route.collect_local_models(tmp_path / "models")
[row] = [row for row in rows if row.model_id == "Org/Model"]
assert row.id == str(snapshot.resolve())
assert row.partial is False
assert row.active_cache is False
def test_list_cached_gguf_includes_non_suffix_repo_when_cache_contains_gguf(monkeypatch, tmp_path):
repo = _repo(
"HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
@ -131,6 +191,72 @@ def test_is_hidden_model_hides_validation_probe_everywhere():
assert not models_route._is_hidden_model("user/stories260K-finetune-GGUF")
def test_is_hidden_model_hides_dictation_models(tmp_path):
assert models_route._is_hidden_model("unsloth/whisper-tiny")
assert models_route._is_hidden_model("unsloth/whisper-base")
assert models_route._is_hidden_model("unsloth/whisper-small")
assert models_route._is_hidden_model("unsloth/whisper-large-v3-turbo")
assert models_route._is_hidden_model(
"/hf/models--unsloth--whisper-large-v3/snapshots/abc/model.safetensors"
)
assert not models_route._is_hidden_model("user/whisper-finetune")
assert not models_route._is_hidden_model(
"C:\\cache\\models--unsloth--whisper-small-finetune\\model.safetensors"
)
custom = tmp_path / "custom-whisper"
custom.mkdir()
(custom / "config.json").write_text(
'{"model_type": "whisper", "architectures": ["WhisperForConditionalGeneration"]}'
)
(custom / "model.safetensors").write_bytes(b"weights")
assert models_route._is_hidden_model(
"user/custom-checkpoint",
str(custom / "model.safetensors"),
)
named_only = tmp_path / "whisper-finetune"
named_only.mkdir()
(named_only / "config.json").write_text('{"model_type": "llama"}')
assert not models_route._is_hidden_model("user/whisper-finetune", str(named_only))
def test_list_cached_models_hides_custom_whisper_by_config(monkeypatch, tmp_path):
# Regression: the legacy /cached-models picker must pass the snapshot path so
# the config check hides a custom (non-curated) Whisper checkpoint; a bare
# repo id cannot ("user/whisper-finetune" is not in the curated set).
repo_path = tmp_path / "models--user--whisper-finetune"
snap = repo_path / "snapshots" / "abc"
snap.mkdir(parents = True)
(snap / "config.json").write_text(
'{"model_type": "whisper", "architectures": ["WhisperForConditionalGeneration"]}'
)
(snap / "model.safetensors").write_bytes(b"weights")
captured: list = []
real_hidden = models_route._is_hidden_model
def spy(*values):
captured.append(values)
return real_hidden(*values)
monkeypatch.setattr(models_route, "_is_hidden_model", spy)
repo = _repo(
"user/whisper-finetune",
[SimpleNamespace(file_name = "model.safetensors", size_on_disk = 10)],
repo_path,
)
monkeypatch.setattr(
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo])]
)
result = asyncio.run(
models_route.list_cached_models(current_subject = "test-user", hf_token = None)
)
# The route passed the snapshot path (not just the repo id) ...
assert any(str(repo_path) in values for values in captured)
# ... so the custom Whisper checkpoint is hidden from the chat picker.
assert result["cached"] == []
def test_is_hidden_model_matches_repo_ids_exactly(monkeypatch):
"""A custom embedder with a generic basename is hidden by EXACT repo-id
match only, so unrelated cached repos that merely contain the basename stay
@ -573,33 +699,14 @@ def _gfile(name: str, size: int, mtime: float) -> SimpleNamespace:
)
def test_all_hf_cache_scans_survives_inaccessible_aux_cache(monkeypatch, tmp_path):
"""An unreadable auxiliary cache (e.g. an inaccessible
``~/.cache/huggingface/hub``) must be skipped, not abort the scan.
Regression guard for ``extra.is_dir()`` raising and wiping the response.
"""
import huggingface_hub
import utils.paths as paths_mod
def test_all_hf_cache_scans_uses_shared_inventory(monkeypatch, tmp_path):
from hub.utils import inventory_scan
active = SimpleNamespace(
repos = [_repo("Org/Active", [_file("Q4_K_M.gguf", 5_000)], tmp_path / "active")]
)
def _fake_scan(cache_dir = None):
if cache_dir is None:
return active
raise AssertionError("auxiliary scan should have been skipped")
class _Boom:
def is_dir(self):
raise PermissionError(13, "Permission denied")
def resolve(self):
raise PermissionError(13, "Permission denied")
monkeypatch.setattr(huggingface_hub, "scan_cache_dir", _fake_scan)
monkeypatch.setattr(paths_mod, "legacy_hf_cache_dir", lambda: _Boom())
monkeypatch.setattr(paths_mod, "hf_default_cache_dir", lambda: _Boom())
monkeypatch.setattr(inventory_scan, "all_hf_cache_scans", lambda: [active])
scans = models_route._all_hf_cache_scans()
assert scans == [active]
@ -686,13 +793,17 @@ def test_gguf_variants_mmproj_does_not_mark_quant_downloaded(monkeypatch, tmp_pa
"list_gguf_variants",
lambda repo_id, hf_token = None: (variants, True, []),
)
monkeypatch.setattr(GV, "_local_main_gguf_blobs_by_quant", lambda _repo_id: {})
monkeypatch.setattr(
GV,
"_local_main_gguf_blobs_by_quant",
lambda _repo_id, repo_cache_dir = None: {},
)
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
snap.mkdir(parents = True)
(snap / "model-Q4_K_M.gguf").write_bytes(b"x" * 10_000) # real weight, fully present
(snap / "mmproj-F16.gguf").write_bytes(b"y" * 20_000) # mmproj adapter, label "F16"
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id: [snap])
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id, root = None: [snap])
result = asyncio.run(
models_route.get_gguf_variants(
@ -705,6 +816,52 @@ def test_gguf_variants_mmproj_does_not_mark_quant_downloaded(monkeypatch, tmp_pa
assert flags["F16"] is False
def test_gguf_variants_route_scopes_local_probe_to_selected_cache(monkeypatch, tmp_path):
snapshot = tmp_path / "inactive" / "models--org--repo" / "snapshots" / "rev"
snapshot.mkdir(parents = True)
calls = []
async def scoped_variants(repo_id, **kwargs):
calls.append((repo_id, kwargs))
return SimpleNamespace(
repo_id = repo_id,
variants = [],
has_vision = False,
default_variant = None,
)
context_calls = []
monkeypatch.setattr(GV, "get_gguf_variants_response", scoped_variants)
monkeypatch.setattr(
models_route,
"_read_native_context_length",
lambda model, *, is_local: context_calls.append((model, is_local)) or 8192,
)
result = asyncio.run(
models_route.get_gguf_variants(
repo_id = "org/repo",
prefer_local_cache = True,
local_path = str(snapshot),
hf_token = None,
current_subject = "test-user",
)
)
assert calls == [
(
"org/repo",
{
"prefer_local_cache": True,
"local_path": str(snapshot),
"hf_token": None,
},
)
]
assert context_calls == [(str(snapshot), True)]
assert result.context_length == 8192
def test_gguf_variants_ignore_big_endian_siblings(monkeypatch, tmp_path):
siblings = [
SimpleNamespace(rfilename = "model-Q4_K_M-be.gguf", size = 100),
@ -726,12 +883,16 @@ def test_gguf_variants_ignore_big_endian_siblings(monkeypatch, tmp_path):
siblings,
),
)
monkeypatch.setattr(GV, "_local_main_gguf_blobs_by_quant", lambda _repo_id: {})
monkeypatch.setattr(
GV,
"_local_main_gguf_blobs_by_quant",
lambda _repo_id, repo_cache_dir = None: {},
)
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
snap.mkdir(parents = True)
(snap / "model-Q4_K_M.gguf").write_bytes(b"x" * 10)
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id: [snap])
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id, root = None: [snap])
result = asyncio.run(
models_route.get_gguf_variants(
@ -758,12 +919,16 @@ def test_gguf_variants_cached_big_endian_does_not_satisfy_variant(monkeypatch, t
"list_gguf_variants",
lambda repo_id, hf_token = None: (variants, False, []),
)
monkeypatch.setattr(GV, "_local_main_gguf_blobs_by_quant", lambda _repo_id: {})
monkeypatch.setattr(
GV,
"_local_main_gguf_blobs_by_quant",
lambda _repo_id, repo_cache_dir = None: {},
)
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
snap.mkdir(parents = True)
(snap / "model-Q4_K_M-be.gguf").write_bytes(b"x" * 10)
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id: [snap])
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id, root = None: [snap])
result = asyncio.run(
models_route.get_gguf_variants(
@ -774,66 +939,82 @@ def test_gguf_variants_cached_big_endian_does_not_satisfy_variant(monkeypatch, t
assert result.variants[0].downloaded is False
def test_gguf_download_progress_excludes_mmproj(monkeypatch, tmp_path):
"""A cached mmproj adapter must not count toward a same-label main
variant's download progress (mmproj-F16 vs an F16 weight)."""
import huggingface_hub.constants as hf_constants
def test_legacy_gguf_progress_delegates_to_shared_service(monkeypatch):
calls = []
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
snap.mkdir(parents = True)
(snap / "mmproj-F16.gguf").write_bytes(b"y" * 20_000) # only the adapter on disk
async def shared(repo_id, *, variant, expected_bytes, hf_token):
calls.append((repo_id, variant, expected_bytes, hf_token))
return {"downloaded_bytes": 10, "expected_bytes": 20, "progress": 0.5}
result = asyncio.run(
models_route.get_gguf_download_progress(
repo_id = "org/repo",
variant = "F16",
expected_bytes = 20_000,
current_subject = "test-user",
)
monkeypatch.setattr(
"hub.services.models.downloads.get_gguf_download_progress_response",
shared,
)
assert result["downloaded_bytes"] == 0
assert result["progress"] == 0
def test_gguf_download_progress_excludes_big_endian_sibling(monkeypatch, tmp_path):
import huggingface_hub.constants as hf_constants
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
snap.mkdir(parents = True)
(snap / "model-Q4_K_M-be.gguf").write_bytes(b"y" * 20_000)
result = asyncio.run(
models_route.get_gguf_download_progress(
repo_id = "org/repo",
variant = "Q4_K_M",
expected_bytes = 20_000,
expected_bytes = 20,
hf_token = "token",
current_subject = "test-user",
)
)
assert result["downloaded_bytes"] == 0
assert result["progress"] == 0
assert result["progress"] == 0.5
assert calls == [("org/repo", "Q4_K_M", 20, "token")]
def test_gguf_download_progress_counts_quant_subdir(monkeypatch, tmp_path):
import huggingface_hub.constants as hf_constants
def test_legacy_model_progress_delegates_to_shared_service(monkeypatch):
calls = []
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
snap = tmp_path / "models--org--repo" / "snapshots" / "rev" / "Q4_K_M"
snap.mkdir(parents = True)
(snap / "foo.gguf").write_bytes(b"x" * 20_000)
async def shared(repo_id, *, hf_token):
calls.append((repo_id, hf_token))
return {"downloaded_bytes": 10, "expected_bytes": 20, "progress": 0.5}
monkeypatch.setattr(
"hub.services.models.downloads.get_download_progress_response",
shared,
)
result = asyncio.run(
models_route.get_gguf_download_progress(
models_route.get_download_progress(
repo_id = "org/repo",
variant = "Q4_K_M",
expected_bytes = 20_000,
hf_token = "token",
current_subject = "test-user",
)
)
assert result["downloaded_bytes"] == 20_000
assert result["progress"] == 1.0
assert result["progress"] == 0.5
assert calls == [("org/repo", "token")]
def test_legacy_delete_delegates_to_shared_service(monkeypatch):
calls = []
async def shared(
repo_id,
variant,
hf_token,
cache_path = None,
):
calls.append((repo_id, variant, hf_token, cache_path))
return {"status": "deleted", "repo_id": repo_id}
monkeypatch.setattr(
"hub.services.models.deletion.delete_cached_model_response",
shared,
)
result = asyncio.run(
models_route.delete_cached_model(
repo_id = "org/repo",
variant = None,
cache_path = "/data/hf/hub",
hf_token = "token",
current_subject = "test-user",
)
)
assert result == {"status": "deleted", "repo_id": "org/repo"}
assert calls == [("org/repo", None, "token", "/data/hf/hub")]

View file

@ -0,0 +1,75 @@
# 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 asyncio
import importlib.util
import sys
from pathlib import Path
import pytest
from fastapi import HTTPException
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
from models.auth import ChangePasswordRequest # noqa: E402
# Load routes/auth.py directly so collection does not execute routes/__init__.py,
# which pulls in the heavy training/models/inference routers.
_route_path = _BACKEND_ROOT / "routes" / "auth.py"
_spec = importlib.util.spec_from_file_location("_change_password_route", _route_path)
assert _spec is not None and _spec.loader is not None
auth_routes = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(auth_routes)
@pytest.fixture
def _user(monkeypatch):
monkeypatch.setattr(
auth_routes.storage,
"get_user_and_secret",
lambda username: ("salt", "hash", "jwt-secret", False),
)
monkeypatch.setattr(
auth_routes.hashing,
"verify_password",
lambda password, salt, pwd_hash: password == "bootstrap-pw",
)
def _change(new_password):
payload = ChangePasswordRequest(
current_password = "bootstrap-pw",
new_password = new_password,
)
return asyncio.run(auth_routes.change_password(payload, None, "unsloth"))
def test_rejects_whitespace_only_password(_user):
with pytest.raises(HTTPException) as excinfo:
_change(" " * 8)
assert excinfo.value.status_code == 400
assert "spaces" in excinfo.value.detail
def test_rejects_tabs_and_spaces_password(_user):
with pytest.raises(HTTPException) as excinfo:
_change(" \t \t \t \t ")
assert excinfo.value.status_code == 400
def test_rejects_password_containing_spaces(_user):
with pytest.raises(HTTPException) as excinfo:
_change("correct horse battery")
assert excinfo.value.status_code == 400
assert "spaces" in excinfo.value.detail
def test_allows_password_without_spaces(_user, monkeypatch):
monkeypatch.setattr(auth_routes.storage, "update_password", lambda *args, **kwargs: True)
monkeypatch.setattr(auth_routes, "create_access_token", lambda subject: "at")
monkeypatch.setattr(auth_routes, "create_refresh_token", lambda subject: "rt")
token = _change("correct-horse-battery")
assert token.access_token == "at"
assert token.must_change_password is False

View file

@ -0,0 +1,735 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Hermetic tests for the combined llama+whisper update item.
llama.cpp is the single main update item; whisper.cpp piggybacks on it. These
pin the union status (update_available = llama behind OR whisper behind), the
chained apply (llama phase first, whisper phase only when behind), the failure
policy (llama failure aborts; whisper failure keeps the llama partial success),
the silent whisper skips, and the backward-compatible payload shape.
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
import pytest
_BACKEND = Path(__file__).resolve().parents[1]
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
import utils.llama_cpp_freshness as freshness # noqa: E402
import utils.llama_cpp_update as upd # noqa: E402
import utils.whisper_cpp_freshness as wfresh # noqa: E402
import utils.whisper_cpp_update as wupd # noqa: E402
MARKER = "UNSLOTH_PREBUILT_INFO.json"
WHISPER_MARKER = "UNSLOTH_WHISPER_PREBUILT_INFO.json"
# The top-level status and job fields that predate the whisper piggyback; the
# combined payload must stay an exact superset so current UI code keeps working.
LEGACY_STATUS_FIELDS = {
"supported",
"update_available",
"stale",
"installed_tag",
"latest_tag",
"published_repo",
"installed_at_utc",
"age_days",
"source_build",
"update_size_bytes",
"job",
}
LEGACY_JOB_FIELDS = {
"state",
"message",
"from_tag",
"to_tag",
"reload_required",
"error",
"progress",
"started_at",
"finished_at",
}
class _FakeInstallerPopen:
"""Stands in for the streamed llama installer process."""
def __init__(
self,
cmd,
*,
returncode = 0,
lines = None,
on_start = None,
**kwargs,
):
if on_start is not None:
on_start(list(cmd))
self.returncode = returncode
self.stdout = iter(lines or [])
def wait(self):
return self.returncode
def kill(self):
pass
def _patch_llama_installer(
monkeypatch,
*,
returncode = 0,
lines = None,
on_start = None,
):
# Only intercept the installer invocation: importing routes.inference inside
# the worker can Popen unrelated host probes (ldconfig etc).
def _popen(cmd, **kw):
is_installer = any("install_llama_prebuilt" in str(part) for part in cmd)
return _FakeInstallerPopen(
cmd,
returncode = returncode if is_installer else 0,
lines = lines if is_installer else None,
on_start = on_start if is_installer else None,
)
monkeypatch.setattr(upd.subprocess, "Popen", _popen)
def _write_llama_install(dir_: Path, tag: str) -> str:
"""Create a fake llama prebuilt install and return the llama-server path."""
bin_dir = dir_ / "build" / "bin"
bin_dir.mkdir(parents = True, exist_ok = True)
binary = bin_dir / "llama-server"
binary.write_text("stub")
(dir_ / MARKER).write_text(
json.dumps(
{
"tag": tag,
"release_tag": tag,
"published_repo": "unslothai/llama.cpp",
"installed_at_utc": "2020-01-01T00:00:00Z",
}
)
)
return str(binary)
def _write_whisper_install(
dir_: Path,
tag: str,
backend: str = "cpu",
) -> str:
"""Create a fake whisper prebuilt install and return the whisper-server path."""
bin_dir = dir_ / "build" / "bin"
bin_dir.mkdir(parents = True, exist_ok = True)
binary = bin_dir / "whisper-server"
binary.write_text("stub")
(dir_ / WHISPER_MARKER).write_text(
json.dumps(
{
"release_tag": tag,
"upstream_tag": tag.split("-")[0],
"published_repo": "unslothai/whisper.cpp",
"backend": backend,
"installed_at_utc": "2020-01-01T00:00:00Z",
}
)
)
return str(binary)
@pytest.fixture(autouse = True)
def _clean_state(monkeypatch, tmp_path):
freshness.reset_caches()
wfresh.reset_caches()
upd._reset_job_for_tests()
upd._resolve_memo.clear()
wupd._resolve_memo.clear()
monkeypatch.setattr(freshness, "_cache_dir", lambda: tmp_path / ".llama_cache")
monkeypatch.setattr(wfresh, "_cache_dir", lambda: tmp_path / ".whisper_cache")
for var in (
"LLAMA_SERVER_PATH",
"UNSLOTH_LLAMA_CPP_PATH",
"WHISPER_SERVER_PATH",
"UNSLOTH_WHISPER_CPP_PATH",
):
monkeypatch.delenv(var, raising = False)
# Never hit the network in these tests.
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: None)
monkeypatch.setattr(wfresh, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: None)
yield
freshness.reset_caches()
wfresh.reset_caches()
upd._reset_job_for_tests()
upd._resolve_memo.clear()
wupd._resolve_memo.clear()
def _setup_llama(
monkeypatch,
tmp_path,
*,
installed = "b9493",
latest = "b9518",
):
"""Marker-managed llama install; behind when installed != latest."""
install_dir = tmp_path / "llama.cpp"
binary = _write_llama_install(install_dir, installed)
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: latest)
return install_dir
def _setup_whisper(
monkeypatch,
tmp_path,
*,
installed = "v1.9.1-unsloth.1",
latest = "v1.9.2-unsloth.1",
):
"""Marker-managed whisper install; behind when latest is newer."""
install_dir = tmp_path / "whisper.cpp"
binary = _write_whisper_install(install_dir, installed)
monkeypatch.setattr(wupd, "_find_binary", lambda: binary)
monkeypatch.setattr(wupd, "_installer_script", lambda: tmp_path / "install_whisper_prebuilt.py")
monkeypatch.setattr(wfresh, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: latest)
return install_dir
def _patch_whisper_phase(
monkeypatch,
events,
*,
to_tag = "v1.9.2-unsloth.1",
error = None,
):
"""Record whisper phase runs without touching a real installer."""
def _run(phase, set_progress):
events.append("whisper")
if error is not None:
raise RuntimeError(error)
set_progress(0.5)
return {
"to_tag": to_tag,
"reload_required": False,
"message": f"Updated whisper.cpp to {to_tag}.",
}
monkeypatch.setattr(wupd, "run_chained_phase", _run)
def _wait_for_job():
deadline = time.time() + 10
while time.time() < deadline:
with upd._job_lock:
job = dict(upd._job)
if job["state"] in ("success", "error"):
return job
time.sleep(0.05)
with upd._job_lock:
return dict(upd._job)
# --- status: the single item folds whisper in ---
def test_status_payload_is_exact_superset_of_legacy_fields(monkeypatch, tmp_path):
_setup_llama(monkeypatch, tmp_path)
_setup_whisper(monkeypatch, tmp_path)
st = upd.get_update_status(force_refresh = True)
assert LEGACY_STATUS_FIELDS <= set(st)
assert LEGACY_JOB_FIELDS <= set(st["job"])
# The new fields ride alongside, never replacing the legacy ones.
assert st["llama_update_available"] is True
assert st["whisper"]["update_available"] is True
assert st["whisper"]["latest_tag"] == "v1.9.2-unsloth.1"
assert st["update_component"] == "llama"
def test_status_union_whisper_only_surfaces_update(monkeypatch, tmp_path):
# llama current, whisper behind: the single item still shows an update.
_setup_llama(monkeypatch, tmp_path, installed = "b9518", latest = "b9518")
_setup_whisper(monkeypatch, tmp_path)
st = upd.get_update_status(force_refresh = True)
assert st["llama_update_available"] is False
assert st["whisper"]["update_available"] is True
assert st["update_available"] is True
assert st["update_component"] == "whisper"
assert st["installed_tag"] == "b9518"
assert st["latest_tag"] == "b9518"
assert st["whisper"]["installed_tag"] == "v1.9.1-unsloth.1"
assert st["whisper"]["latest_tag"] == "v1.9.2-unsloth.1"
def test_status_whisper_current_does_not_flip_union(monkeypatch, tmp_path):
_setup_llama(monkeypatch, tmp_path, installed = "b9518", latest = "b9518")
_setup_whisper(monkeypatch, tmp_path, installed = "v1.9.2-unsloth.1", latest = "v1.9.2-unsloth.1")
st = upd.get_update_status(force_refresh = True)
assert st["update_available"] is False
assert st["whisper"]["skip_reason"] == "up_to_date"
assert st["update_component"] is None
def test_status_survives_whisper_probe_failure(monkeypatch, tmp_path):
# The piggyback fails open: llama status still works without a whisper probe.
_setup_llama(monkeypatch, tmp_path)
def _boom(*, force_refresh = False):
raise RuntimeError("probe exploded")
monkeypatch.setattr(wupd, "chained_phase_plan", _boom)
st = upd.get_update_status(force_refresh = True)
assert st["update_available"] is True
assert st["whisper"] is None
# --- whisper chained_phase_plan: silent skips ---
def test_whisper_plan_skips_local_link(monkeypatch, tmp_path):
monkeypatch.setattr(wupd, "_find_binary", lambda: str(tmp_path / "whisper-server"))
monkeypatch.setattr(wupd, "_active_install_is_local_link", lambda b: True)
plan = wupd.chained_phase_plan()
assert plan["update_available"] is False
assert plan["skip_reason"] == "local_link"
assert plan["phase"] is None
def test_whisper_plan_skips_source_build(monkeypatch, tmp_path):
binary = tmp_path / "whisper.cpp" / "build" / "bin" / "whisper-server"
binary.parent.mkdir(parents = True)
binary.write_text("stub") # no marker
monkeypatch.setattr(wupd, "_find_binary", lambda: str(binary))
plan = wupd.chained_phase_plan()
assert plan["skip_reason"] == "source_build"
assert plan["phase"] is None
def test_whisper_update_targets_canonical_root_when_inner_marker_exists(tmp_path):
install_dir = tmp_path / "whisper.cpp"
binary = install_dir / "build" / "bin" / "whisper-server"
binary.parent.mkdir(parents = True)
binary.write_text("stub")
(install_dir / WHISPER_MARKER).write_text("{}")
(binary.parent / WHISPER_MARKER).write_text("{}")
assert wupd._install_dir_for(str(binary)) == install_dir
def test_whisper_plan_skips_when_not_installed(monkeypatch):
monkeypatch.setattr(wupd, "_find_binary", lambda: None)
plan = wupd.chained_phase_plan()
assert plan["skip_reason"] == "not_installed"
assert plan["phase"] is None
def test_whisper_plan_eligible_when_behind(monkeypatch, tmp_path):
install_dir = _setup_whisper(monkeypatch, tmp_path)
script = tmp_path / "install_whisper_prebuilt.py"
script.write_text("stub")
plan = wupd.chained_phase_plan(force_refresh = True)
assert plan["update_available"] is True
assert plan["skip_reason"] is None
assert plan["phase"]["install_dir"] == install_dir
assert plan["phase"]["repo"] == "unslothai/whisper.cpp"
assert plan["phase"]["backend"] == "cpu"
# Pin to the exact release the freshness check offered: unpinned, the
# installer's download-host /releases/latest pointer can lag published_at
# and reinstall an older build in a loop.
assert plan["phase"]["pin_release_tag"] == "v1.9.2-unsloth.1"
def test_whisper_plan_requires_a_repairable_pair_for_slim_installs(monkeypatch, tmp_path):
install_dir = _setup_whisper(monkeypatch, tmp_path)
marker_path = install_dir / WHISPER_MARKER
marker = json.loads(marker_path.read_text())
marker["install_kind"] = "slim"
marker_path.write_text(json.dumps(marker))
wfresh.reset_caches()
monkeypatch.setattr(
wupd,
"_resolve_prebuilt_for_host",
lambda **kwargs: {"prebuilt_available": False},
)
plan = wupd.chained_phase_plan(force_refresh = True)
assert plan["update_available"] is False
assert plan["skip_reason"] == "paired_llama_unavailable"
repaired = wupd.chained_phase_plan(
force_refresh = True,
paired_llama_will_update = True,
)
assert repaired["update_available"] is True
assert repaired["phase"] is not None
def test_whisper_phase_pins_installer_to_checked_release(monkeypatch, tmp_path):
calls = []
monkeypatch.setattr(
wupd._flow,
"stream_installer",
lambda cmd, env, **kw: calls.append(cmd),
)
monkeypatch.setattr(wupd, "reset_caches", lambda **kw: None)
monkeypatch.setattr(wupd, "latest_published_release", lambda repo, **kw: "v9")
install_dir = tmp_path / "whisper.cpp"
binary = _write_whisper_install(install_dir, "v9")
monkeypatch.setattr(wupd, "_find_binary", lambda: binary)
wupd.run_chained_phase(
{
"install_dir": install_dir,
"repo": "unslothai/whisper.cpp",
"asset": None,
"backend": "cpu",
"script": tmp_path / "install_whisper_prebuilt.py",
"pin_release_tag": "v9",
},
lambda f: None,
)
cmd = calls[0]
assert "--published-release-tag" in cmd
assert cmd[cmd.index("--published-release-tag") + 1] == "v9"
def test_whisper_phase_exit_2_is_a_failed_phase(monkeypatch, tmp_path):
# No install occurred, so incompatibility must remain an actionable job
# error instead of producing a false success toast and hiding the banner.
def _raise_exit_2(cmd, env, **kw):
raise wupd._flow.InstallerExit(2, "installer exited 2: incompatible release")
monkeypatch.setattr(wupd._flow, "stream_installer", _raise_exit_2)
install_dir = tmp_path / "whisper.cpp"
binary = _write_whisper_install(install_dir, "v1")
monkeypatch.setattr(wupd, "_find_binary", lambda: binary)
with pytest.raises(wupd._flow.InstallerExit) as exc_info:
wupd.run_chained_phase(
{
"install_dir": install_dir,
"repo": "unslothai/whisper.cpp",
"asset": None,
"backend": "cpu",
"script": tmp_path / "install_whisper_prebuilt.py",
"pin_release_tag": None,
},
lambda f: None,
)
assert exc_info.value.returncode == 2
def test_llama_update_survives_unavailable_whisper_module(monkeypatch, tmp_path):
import builtins
llama_dir = _setup_llama(monkeypatch, tmp_path)
monkeypatch.setattr(upd, "_whisper_chain_status", lambda **kw: None)
_patch_llama_installer(
monkeypatch,
on_start = lambda cmd: _write_llama_install(llama_dir, "b9518"),
)
real_import = builtins.__import__
def guarded_import(
name,
globals = None,
locals = None,
fromlist = (),
level = 0,
):
if name == "utils" and "whisper_cpp_update" in fromlist:
raise AssertionError("whisper module was re-imported after its failed probe")
return real_import(name, globals, locals, fromlist, level)
monkeypatch.setattr(builtins, "__import__", guarded_import)
# A failed optional whisper probe must not be followed by an unconditional
# import. The valid llama phase still starts and completes.
assert upd.start_update()["started"] is True
job = _wait_for_job()
assert job["state"] == "success", job
assert job["phases"]["llama"]["state"] == "success"
assert job["phases"]["whisper"]["state"] == "skipped"
assert job["phases"]["whisper"]["reason"] == "unavailable"
def test_macos_status_uses_compatible_resolver_release(monkeypatch, tmp_path):
_setup_whisper(
monkeypatch,
tmp_path,
installed = "v1.9.1-unsloth.1",
latest = "v1.9.2-unsloth.1",
)
monkeypatch.setattr(wupd.sys, "platform", "darwin")
monkeypatch.setattr(
wupd,
"_resolve_prebuilt_for_host",
lambda **kw: {
"prebuilt_available": True,
"release_tag": "v1.9.1-unsloth.1",
},
)
status = wupd.get_update_status(force_refresh = True)
assert status["latest_tag"] == "v1.9.1-unsloth.1"
assert status["update_available"] is False
assert status["stale"] is False
def test_whisper_phase_integrity_failure_is_not_swallowed(monkeypatch, tmp_path):
def _raise_exit_1(cmd, env, **kw):
raise wupd._flow.InstallerExit(1, "installer exited 1: checksum mismatch")
monkeypatch.setattr(wupd._flow, "stream_installer", _raise_exit_1)
install_dir = tmp_path / "whisper.cpp"
binary = _write_whisper_install(install_dir, "v1")
monkeypatch.setattr(wupd, "_find_binary", lambda: binary)
with pytest.raises(wupd._flow.InstallerExit, match = "checksum mismatch"):
wupd.run_chained_phase(
{
"install_dir": install_dir,
"repo": "unslothai/whisper.cpp",
"asset": None,
"backend": "cpu",
"script": tmp_path / "install_whisper_prebuilt.py",
"pin_release_tag": None,
},
lambda f: None,
)
# --- apply: the chained job ---
def test_apply_runs_llama_then_whisper(monkeypatch, tmp_path):
llama_dir = _setup_llama(monkeypatch, tmp_path)
_setup_whisper(monkeypatch, tmp_path)
(tmp_path / "install_whisper_prebuilt.py").write_text("stub")
events = []
_patch_llama_installer(
monkeypatch,
on_start = lambda cmd: (events.append("llama"), _write_llama_install(llama_dir, "b9518")),
)
_patch_whisper_phase(monkeypatch, events)
res = upd.start_update()
assert res["started"] is True, res
job = _wait_for_job()
assert job["state"] == "success", job
assert events == ["llama", "whisper"] # llama phase strictly first
assert job["phases"]["llama"]["state"] == "success"
assert job["phases"]["llama"]["to_tag"] == "b9518"
assert job["phases"]["whisper"]["state"] == "success"
assert job["phases"]["whisper"]["to_tag"] == "v1.9.2-unsloth.1"
# Legacy top-level fields keep their llama meaning.
assert job["from_tag"] == "b9493"
assert job["to_tag"] == "b9518"
assert "Updated llama.cpp to b9518." in job["message"]
assert "Updated whisper.cpp to v1.9.2-unsloth.1." in job["message"]
assert job["progress"] == 1.0
assert LEGACY_JOB_FIELDS <= set(job)
def test_apply_llama_only_when_whisper_current(monkeypatch, tmp_path):
llama_dir = _setup_llama(monkeypatch, tmp_path)
_setup_whisper(monkeypatch, tmp_path, installed = "v1.9.2-unsloth.1", latest = "v1.9.2-unsloth.1")
events = []
_patch_llama_installer(
monkeypatch,
on_start = lambda cmd: (events.append("llama"), _write_llama_install(llama_dir, "b9518")),
)
_patch_whisper_phase(monkeypatch, events)
assert upd.start_update()["started"] is True
job = _wait_for_job()
assert job["state"] == "success", job
assert events == ["llama"]
assert job["phases"]["whisper"]["state"] == "skipped"
assert job["phases"]["whisper"]["reason"] == "up_to_date"
def test_apply_whisper_only_noops_llama(monkeypatch, tmp_path):
# llama current + whisper behind: the same single apply runs, with the llama
# phase a cheap already-matches no-op and the whisper phase doing the work.
_setup_llama(monkeypatch, tmp_path, installed = "b9518", latest = "b9518")
_setup_whisper(monkeypatch, tmp_path)
(tmp_path / "install_whisper_prebuilt.py").write_text("stub")
events = []
_patch_llama_installer(monkeypatch, on_start = lambda cmd: events.append("llama"))
_patch_whisper_phase(monkeypatch, events)
res = upd.start_update()
assert res["started"] is True, res
job = _wait_for_job()
assert job["state"] == "success", job
assert events == ["whisper"] # the llama installer never ran
# The legacy job-level to_tag means "llama tag"; a whisper-only round
# leaves it unset so the UI never reports a llama update that never ran.
assert job["to_tag"] is None
assert job["phases"]["llama"]["state"] == "skipped"
assert job["phases"]["llama"]["reason"] == "up_to_date"
assert job["phases"]["whisper"]["state"] == "success"
assert "Updated whisper.cpp to v1.9.2-unsloth.1." in job["message"]
def test_whisper_reload_never_raises_job_reload_flag(monkeypatch, tmp_path):
# A whisper-only update that had to unload a warm sidecar reports
# reload_required on its phase, but the JOB flag stays down: the chat
# frontend resyncs (and clears the local checkpoint) off the job flag,
# which must mean "the llama server changed", not "the sidecar restarted".
_setup_llama(monkeypatch, tmp_path, installed = "b9518", latest = "b9518")
_setup_whisper(monkeypatch, tmp_path)
(tmp_path / "install_whisper_prebuilt.py").write_text("stub")
def _whisper_phase(phase, set_progress):
return {
"to_tag": "v1.9.2-unsloth.1",
"reload_required": True,
"message": "Updated whisper.cpp to v1.9.2-unsloth.1.",
}
monkeypatch.setattr(wupd, "run_chained_phase", _whisper_phase)
assert upd.start_update()["started"] is True
job = _wait_for_job()
assert job["state"] == "success", job
assert job["phases"]["whisper"]["reload_required"] is True
assert not job["reload_required"]
def test_apply_refuses_when_both_current(monkeypatch, tmp_path):
_setup_llama(monkeypatch, tmp_path, installed = "b9518", latest = "b9518")
_setup_whisper(monkeypatch, tmp_path, installed = "v1.9.2-unsloth.1", latest = "v1.9.2-unsloth.1")
res = upd.start_update()
assert res["started"] is False
assert res["reason"] == "up_to_date"
def test_apply_llama_failure_aborts_before_whisper(monkeypatch, tmp_path):
_setup_llama(monkeypatch, tmp_path)
_setup_whisper(monkeypatch, tmp_path)
(tmp_path / "install_whisper_prebuilt.py").write_text("stub")
events = []
_patch_llama_installer(monkeypatch, returncode = 2, lines = ["boom: disk full\n"])
_patch_whisper_phase(monkeypatch, events)
assert upd.start_update()["started"] is True
job = _wait_for_job()
assert job["state"] == "error", job
assert "boom" in (job["error"] or "")
assert events == [] # whisper never attempted
assert job["phases"]["llama"]["state"] == "error"
assert job["phases"]["whisper"]["state"] == "skipped"
assert job["phases"]["whisper"]["reason"] == "aborted"
assert job["message"] == "llama.cpp update failed."
def test_apply_whisper_failure_keeps_llama_partial_success(monkeypatch, tmp_path):
llama_dir = _setup_llama(monkeypatch, tmp_path)
_setup_whisper(monkeypatch, tmp_path)
(tmp_path / "install_whisper_prebuilt.py").write_text("stub")
# An active model makes the llama phase report reload_required.
import threading
from types import ModuleType
class _FakeBackend:
def __init__(self):
self._serial_load_lock = threading.Lock()
self._llama_update_in_progress = False
self.is_active = True
def unload_model(self):
self.is_active = False
backend = _FakeBackend()
routes_pkg = ModuleType("routes")
routes_pkg.__path__ = []
inference_mod = ModuleType("routes.inference")
inference_mod.get_llama_cpp_backend = lambda: backend
monkeypatch.setitem(sys.modules, "routes", routes_pkg)
monkeypatch.setitem(sys.modules, "routes.inference", inference_mod)
events = []
_patch_llama_installer(
monkeypatch,
on_start = lambda cmd: (events.append("llama"), _write_llama_install(llama_dir, "b9518")),
)
_patch_whisper_phase(monkeypatch, events, error = "whisper installer exploded")
assert upd.start_update()["started"] is True
job = _wait_for_job()
assert job["state"] == "error", job
assert events == ["llama", "whisper"]
# The message says both halves: llama landed, whisper did not.
assert "Updated llama.cpp to b9518." in job["message"]
assert "whisper.cpp update failed." in job["message"]
assert "whisper installer exploded" in (job["error"] or "")
# The llama phase's reload_required survives the whisper failure.
assert job["reload_required"] is True
assert job["to_tag"] == "b9518"
assert job["phases"]["llama"]["state"] == "success"
assert job["phases"]["whisper"]["state"] == "error"
def test_apply_skips_whisper_local_link_silently(monkeypatch, tmp_path):
llama_dir = _setup_llama(monkeypatch, tmp_path)
_setup_whisper(monkeypatch, tmp_path)
monkeypatch.setattr(wupd, "_active_install_is_local_link", lambda b: True)
events = []
_patch_llama_installer(
monkeypatch,
on_start = lambda cmd: (events.append("llama"), _write_llama_install(llama_dir, "b9518")),
)
_patch_whisper_phase(monkeypatch, events)
assert upd.start_update()["started"] is True
job = _wait_for_job()
assert job["state"] == "success", job
assert events == ["llama"]
assert job["phases"]["whisper"]["state"] == "skipped"
assert job["phases"]["whisper"]["reason"] == "local_link"
assert job["message"] == "Updated llama.cpp to b9518."
def test_chained_progress_windows(monkeypatch, tmp_path):
# The llama phase fills roughly the first 0.7 slice and whisper the rest.
llama_dir = _setup_llama(monkeypatch, tmp_path)
_setup_whisper(monkeypatch, tmp_path)
(tmp_path / "install_whisper_prebuilt.py").write_text("stub")
seen = {}
def _whisper_phase(phase, set_progress):
with upd._job_lock:
seen["at_whisper_start"] = upd._job["progress"]
set_progress(0.5)
with upd._job_lock:
seen["mid_whisper"] = upd._job["progress"]
return {"to_tag": "v1.9.2-unsloth.1", "reload_required": False, "message": "ok"}
monkeypatch.setattr(wupd, "run_chained_phase", _whisper_phase)
_patch_llama_installer(
monkeypatch,
lines = ["Downloading app.tar.gz: 100.0% (35.0 MiB/35.0 MiB) at 9.0 MiB/s\n"],
on_start = lambda cmd: _write_llama_install(llama_dir, "b9518"),
)
assert upd.start_update()["started"] is True
job = _wait_for_job()
assert job["state"] == "success", job
assert seen["at_whisper_start"] == pytest.approx(0.7)
assert seen["mid_whisper"] == pytest.approx(0.7 + 0.5 * 0.3)
assert job["progress"] == 1.0

View file

@ -873,6 +873,7 @@ class TestScannerCoversAllExecutableCode:
repo,
fn,
token = None,
cache_dir = None,
):
if fn == "config.json":
import json
@ -899,6 +900,7 @@ class TestScannerCoversAllExecutableCode:
repo,
fn,
token = None,
cache_dir = None,
):
import json
import tempfile
@ -932,6 +934,7 @@ class TestScannerCoversAllExecutableCode:
repo,
fn,
token = None,
cache_dir = None,
):
import json
import tempfile
@ -972,6 +975,7 @@ class TestScannerCoversAllExecutableCode:
repo,
fn,
token = None,
cache_dir = None,
):
import json
import tempfile
@ -1008,6 +1012,7 @@ class TestScannerCoversAllExecutableCode:
repo,
fn,
token = None,
cache_dir = None,
):
import json
import tempfile
@ -1037,6 +1042,7 @@ class TestScannerCoversAllExecutableCode:
repo,
fn,
token = None,
cache_dir = None,
):
import json
import tempfile
@ -1079,6 +1085,7 @@ class TestScannerCoversAllExecutableCode:
repo,
fn,
token = None,
cache_dir = None,
):
import json
import tempfile
@ -1120,6 +1127,7 @@ class TestScannerCoversAllExecutableCode:
repo,
fn,
token = None,
cache_dir = None,
):
import json
import tempfile
@ -1182,6 +1190,7 @@ class TestScannerCoversAllExecutableCode:
repo,
fn,
token = None,
cache_dir = None,
):
import json
import tempfile

View file

@ -103,6 +103,10 @@ def _build_cache(
@pytest.fixture
def hf_cache(tmp_path, monkeypatch):
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: _types.SimpleNamespace(hub_cache = tmp_path),
)
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising = False)
return tmp_path
@ -117,6 +121,61 @@ def _fail_get_paths_info(*_args, **_kwargs):
class TestLoadReusesCachedCopy:
def test_download_uses_selected_cache_for_lookup_preflight_and_write(
self, tmp_path, monkeypatch
):
backend = LlamaCppBackend()
selected = tmp_path / "selected" / "hub"
startup = tmp_path / "startup" / "hub"
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(startup))
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: _types.SimpleNamespace(hub_cache = selected),
)
seen = {"lookups": [], "disk": [], "downloads": []}
def cached_lookup(
repo_id,
filename,
*,
cache_dir = None,
**_kwargs,
):
seen["lookups"].append((repo_id, filename, cache_dir))
return None
def disk_usage(path):
seen["disk"].append(str(path))
return _types.SimpleNamespace(free = 1024)
def download(repo_id, filename, _token, **kwargs):
seen["downloads"].append((repo_id, filename, kwargs.get("cache_dir")))
return str(selected / filename)
with (
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: [MAIN]),
patch(
"huggingface_hub.get_paths_info",
lambda _repo, paths, **_kwargs: [
_types.SimpleNamespace(path = path, size = 4) for path in paths
],
),
patch("huggingface_hub.try_to_load_from_cache", cached_lookup),
patch("core.inference.llama_cpp.shutil.disk_usage", disk_usage),
patch(
"core.inference.llama_cpp.hf_hub_download_with_xet_fallback",
download,
),
):
out = backend._download_gguf(hf_repo = REPO, hf_variant = VARIANT)
assert out == str(selected / MAIN)
assert seen == {
"lookups": [(REPO, MAIN, str(selected))],
"disk": [str(selected)],
"downloads": [(REPO, MAIN, str(selected))],
}
def test_online_reuse_after_revision_bump(self, hf_cache):
"""A new repo revision does not replace a complete cached model."""
backend = LlamaCppBackend()

View file

@ -0,0 +1,290 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from __future__ import annotations
import os
import sys
import threading
import time
from pathlib import Path
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
from hub.services.models.common import _local_model_info
from utils import hf_cache_settings
from utils import native_path_leases
@pytest.fixture()
def settings_store(monkeypatch, tmp_path):
store = {}
monkeypatch.setattr(hf_cache_settings, "_EXPLICIT_CACHE_ENV", {})
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg"))
monkeypatch.setattr(
"storage.studio_db.get_app_setting",
lambda key, fallback = None: store.get(key, fallback),
)
monkeypatch.setattr(
"storage.studio_db.upsert_app_settings",
lambda values: store.update(values) or values,
)
return store
def test_studio_cache_switch_is_live_and_keeps_history(settings_store, tmp_path):
first = tmp_path / "external-a" / "huggingface"
second = tmp_path / "external-b" / "huggingface"
first.parent.mkdir()
second.parent.mkdir()
selected = hf_cache_settings.set_hf_cache_home(str(first))
assert selected.hub_cache == first / "hub"
assert selected.xet_cache == first / "xet"
assert selected.child_env({}) == {
"HF_HUB_CACHE": str(first / "hub"),
"HF_XET_CACHE": str(first / "xet"),
}
hf_cache_settings.set_hf_cache_home(str(second))
assert settings_store[hf_cache_settings.CACHE_HISTORY_SETTING_KEY] == [str(first)]
assert first / "hub" in hf_cache_settings.known_hf_hub_caches()
reset = hf_cache_settings.set_hf_cache_home(None)
assert reset.source == "default"
assert second in hf_cache_settings.known_hf_cache_homes()
def test_environment_cache_is_read_only(monkeypatch, tmp_path):
custom = tmp_path / "managed"
monkeypatch.setattr(
hf_cache_settings,
"_EXPLICIT_CACHE_ENV",
{"HF_HOME": str(custom)},
)
paths = hf_cache_settings.get_hf_cache_paths()
assert paths.source == "environment"
assert paths.editable is False
assert paths.hub_cache == custom / "hub"
with pytest.raises(RuntimeError, match = "environment variable"):
hf_cache_settings.set_hf_cache_home(str(tmp_path / "other"))
def test_explicit_hub_cache_is_the_displayed_location(monkeypatch, tmp_path):
custom_hub = tmp_path / "models-cache"
custom_hub.mkdir()
monkeypatch.setattr(
hf_cache_settings,
"_EXPLICIT_CACHE_ENV",
{"HF_HUB_CACHE": str(custom_hub)},
)
paths = hf_cache_settings.get_hf_cache_paths()
status = hf_cache_settings.cache_status(paths)
assert paths.cache_home == custom_hub
assert paths.hub_cache == custom_hub
assert status["cache_home"] == str(custom_hub)
assert status["available"] is True
assert custom_hub / "hub" not in hf_cache_settings.known_hf_hub_caches()
def test_explicit_hub_cache_display_wins_over_hf_home(monkeypatch, tmp_path):
hf_home = tmp_path / "hf-home"
custom_hub = tmp_path / "other-disk" / "models-cache"
hf_home.mkdir()
custom_hub.mkdir(parents = True)
monkeypatch.setattr(
hf_cache_settings,
"_EXPLICIT_CACHE_ENV",
{"HF_HOME": str(hf_home), "HF_HUB_CACHE": str(custom_hub)},
)
paths = hf_cache_settings.get_hf_cache_paths()
assert paths.cache_home == custom_hub
assert paths.hub_cache == custom_hub
assert paths.xet_cache == hf_home / "xet"
assert custom_hub / "hub" not in hf_cache_settings.known_hf_hub_caches()
assert hf_home / "hub" in hf_cache_settings.known_hf_hub_caches()
def test_xet_only_override_keeps_model_cache_editable(settings_store, monkeypatch, tmp_path):
xet_cache = tmp_path / "chunks"
stored = tmp_path / "stored-cache"
settings_store[hf_cache_settings.CACHE_HOME_SETTING_KEY] = str(stored)
monkeypatch.setattr(
hf_cache_settings,
"_EXPLICIT_CACHE_ENV",
{"HF_XET_CACHE": str(xet_cache)},
)
paths = hf_cache_settings.get_hf_cache_paths()
assert paths.cache_home == stored
assert paths.hub_cache == stored / "hub"
assert paths.xet_cache == xet_cache
assert paths.editable is True
selected = tmp_path / "selected-cache"
selected.parent.mkdir(exist_ok = True)
updated = hf_cache_settings.set_hf_cache_home(str(selected))
assert updated.hub_cache == selected / "hub"
assert updated.xet_cache == xet_cache
def test_worker_environment_is_applied_before_import(monkeypatch, tmp_path):
hub = str(tmp_path / "hub")
xet = str(tmp_path / "xet")
observed = {}
class Module:
@staticmethod
def run():
import os
return os.environ["HF_HUB_CACHE"], os.environ["HF_XET_CACHE"]
def fake_import(name):
import os
observed["name"] = name
observed["hub"] = os.environ.get("HF_HUB_CACHE")
return Module
monkeypatch.setattr(native_path_leases.importlib, "import_module", fake_import)
result = native_path_leases.run_without_native_path_secret(
"fake.worker",
"run",
{"HF_HUB_CACHE": hub, "HF_XET_CACHE": xet},
)
assert observed == {"name": "fake.worker", "hub": hub}
assert result == (hub, xet)
def test_spawn_environment_is_applied_then_restored(monkeypatch, tmp_path):
hub = str(tmp_path / "hub")
xet = str(tmp_path / "xet")
monkeypatch.setenv("HF_HUB_CACHE", "parent-hub")
monkeypatch.delenv("HF_XET_CACHE", raising = False)
with hf_cache_settings.child_environment_for_spawn({"HF_HUB_CACHE": hub, "HF_XET_CACHE": xet}):
import os
assert os.environ["HF_HUB_CACHE"] == hub
assert os.environ["HF_XET_CACHE"] == xet
assert os.environ["HF_HUB_CACHE"] == "parent-hub"
assert "HF_XET_CACHE" not in os.environ
def test_spawn_environment_supports_nested_contexts(monkeypatch):
monkeypatch.setenv("HF_HUB_CACHE", "parent")
with hf_cache_settings.child_environment_for_spawn({"HF_HUB_CACHE": "outer"}):
assert os.environ["HF_HUB_CACHE"] == "outer"
with hf_cache_settings.child_environment_for_spawn({"HF_HUB_CACHE": "inner"}):
assert os.environ["HF_HUB_CACHE"] == "inner"
assert os.environ["HF_HUB_CACHE"] == "outer"
assert os.environ["HF_HUB_CACHE"] == "parent"
def test_spawn_environment_serializes_threads(monkeypatch):
monkeypatch.setenv("HF_HUB_CACHE", "parent")
first_entered = threading.Event()
release_first = threading.Event()
observations: list[tuple[str, str]] = []
def first():
with hf_cache_settings.child_environment_for_spawn({"HF_HUB_CACHE": "first"}):
observations.append(("first", os.environ["HF_HUB_CACHE"]))
first_entered.set()
assert release_first.wait(timeout = 2)
def second():
assert first_entered.wait(timeout = 2)
with hf_cache_settings.child_environment_for_spawn({"HF_HUB_CACHE": "second"}):
observations.append(("second", os.environ["HF_HUB_CACHE"]))
first_thread = threading.Thread(target = first)
second_thread = threading.Thread(target = second)
first_thread.start()
second_thread.start()
assert first_entered.wait(timeout = 2)
time.sleep(0.02)
assert observations == [("first", "first")]
release_first.set()
first_thread.join(timeout = 2)
second_thread.join(timeout = 2)
assert observations == [("first", "first"), ("second", "second")]
assert os.environ["HF_HUB_CACHE"] == "parent"
def test_cache_switch_invalidates_inventory(settings_store, tmp_path, monkeypatch):
invalidations = []
monkeypatch.setattr(
"hub.utils.inventory_scan.invalidate_hf_cache_scans",
lambda: invalidations.append(True),
)
selected = tmp_path / "external" / "huggingface"
selected.parent.mkdir()
hf_cache_settings.set_hf_cache_home(str(selected))
assert invalidations == [True]
def test_cache_validation_write_tests_hub_and_xet(settings_store, tmp_path, monkeypatch):
selected = tmp_path / "external" / "huggingface"
selected.parent.mkdir()
tested = []
real_named_temporary_file = hf_cache_settings.tempfile.NamedTemporaryFile
def recording_write_test(*args, **kwargs):
tested.append(Path(kwargs["dir"]))
return real_named_temporary_file(*args, **kwargs)
monkeypatch.setattr(
hf_cache_settings.tempfile,
"NamedTemporaryFile",
recording_write_test,
)
hf_cache_settings.set_hf_cache_home(str(selected))
assert tested == [selected / "hub", selected / "xet"]
def test_cache_validation_rejects_unwritable_child(settings_store, tmp_path, monkeypatch):
selected = tmp_path / "external" / "huggingface"
selected.parent.mkdir()
def reject_hub(*args, **kwargs):
if Path(kwargs["dir"]).name == "hub":
raise PermissionError("read-only")
raise AssertionError("xet should not be tested after hub fails")
monkeypatch.setattr(hf_cache_settings.tempfile, "NamedTemporaryFile", reject_hub)
with pytest.raises(ValueError, match = "permission"):
hf_cache_settings.set_hf_cache_home(str(selected))
def test_inactive_cache_model_loads_from_snapshot_path(tmp_path):
snapshot = tmp_path / "snapshots" / "revision"
snapshot.mkdir(parents = True)
row = _local_model_info(
scan_path = snapshot,
load_path = snapshot,
source = "hf_cache",
model_format = "safetensors",
model_id = "org/model",
active_cache = False,
)
assert row.model_id == "org/model"
assert row.active_cache is False
assert row.load_id == str(snapshot)

View file

@ -101,13 +101,23 @@ def test_shim_injects_studio_prepare_on_http_retry(monkeypatch):
prepared = []
monkeypatch.setattr(
"hub.utils.download_registry.prepare_cache_for_transport",
lambda repo_type, repo_id, mode, *a, **k: prepared.append((repo_type, repo_id, mode)),
lambda repo_type, repo_id, mode, *a, **k: prepared.append(
(repo_type, repo_id, mode, k.get("root"))
),
)
out = xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
selected_cache = "/captured/hub"
out = xf.hf_hub_download_with_xet_fallback(
DL_REPO,
FILE,
None,
cache_dir = selected_cache,
)
assert out == "/cache/model.gguf"
assert seen_disable_xet == [False, True] # Xet first, then HTTP
assert prepared == [("model", DL_REPO, "http")], "shim must run Unsloth's marker-aware prep"
assert prepared == [
("model", DL_REPO, "http", Path(selected_cache))
], "shim must prepare the cache captured by the download"
def test_shim_snapshot_injects_studio_prepare(monkeypatch):
@ -120,10 +130,22 @@ def test_shim_snapshot_injects_studio_prepare(monkeypatch):
return "/tmp/snap-dir"
monkeypatch.setattr(xf, "_shared_snapshot_download_with_xet_fallback", fake_snapshot)
out = xf.snapshot_download_with_xet_fallback("org/model")
selected_cache = "/captured/hub"
out = xf.snapshot_download_with_xet_fallback(
"org/model",
cache_dir = selected_cache,
)
assert out == "/tmp/snap-dir"
assert captured["repo_id"] == "org/model"
assert captured["prepare_for_http_fn"] is xf._studio_prepare_for_http
prepared = []
monkeypatch.setattr(
"hub.utils.download_registry.prepare_cache_for_transport",
lambda repo_type, repo_id, mode, *a, **k: prepared.append(
(repo_type, repo_id, mode, k.get("root"))
),
)
captured["prepare_for_http_fn"]("model", "org/model")
assert prepared == [("model", "org/model", "http", Path(selected_cache))]
def test_degrades_gracefully_without_shared_helper(monkeypatch):

View file

@ -200,14 +200,9 @@ def _gpu_linux_host(caps):
)
def test_host_is_blackwell_includes_datacenter_parts():
assert ilp._host_is_blackwell(_gpu_linux_host(["10.0"])) is True # B200 sm_100
assert ilp._host_is_blackwell(_gpu_linux_host(["10.3"])) is True # B300 sm_103
assert ilp._host_is_blackwell(_gpu_linux_host(["12.0"])) is True # RTX 50 sm_120
assert ilp._host_is_blackwell(_gpu_linux_host(["12.1"])) is True # DGX Spark sm_121
assert ilp._host_is_blackwell(_gpu_linux_host(["9.0"])) is False # Hopper
assert ilp._host_is_blackwell(_gpu_linux_host(["8.0"])) is False # Ampere
assert ilp._host_is_blackwell(_gpu_linux_host(["9.0", "10.0"])) is True # highest cap wins
# _host_is_blackwell / _blackwell_min_toolkit_for_host are prebuilt_core
# re-exports; their value tables moved verbatim to
# tests/studio/install/test_prebuilt_core.py.
def _linux_cuda_artifact(runtime_line, supported_sms, min_sm, max_sm, profile):
@ -285,16 +280,6 @@ def test_drop_blackwell_incapable_windows_cuda_applies_to_datacenter():
assert [a.name for a in kept] == [cuda13.name]
def test_blackwell_min_toolkit_is_sm_aware():
# Family floor is 12.8; sm_103/sm_121 (no native target before 12.9) lift it.
f = ilp._blackwell_min_toolkit_for_host
assert f(_gpu_linux_host(["10.0"])) == (12, 8) # B200
assert f(_gpu_linux_host(["12.0"])) == (12, 8) # RTX 50
assert f(_gpu_linux_host(["10.3"])) == (12, 9) # B300
assert f(_gpu_linux_host(["12.1"])) == (12, 9) # DGX Spark
assert f(_gpu_linux_host(["10.0", "10.3"])) == (12, 9) # max across SMs wins
def test_sm103_host_drops_cuda128_windows_build():
# B300 (sm_103) needs cuda-12.9: a legacy win-cuda-12.8 build must be dropped.
host = _host(

View file

@ -0,0 +1,231 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Trust-anchor tests for install_whisper_prebuilt.py.
Whisper verifies each download against the release's own
whisper-prebuilt-sha256.json checksum index (the same model as
install_llama_prebuilt.py), not a committed pins file. These pin the index
parser, the fail-closed behaviour when an asset is not covered, the
tampered-manifest guard, and the newest-release resolution.
"""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
import pytest
_studio = Path(__file__).resolve().parent.parent.parent
if str(_studio) not in sys.path:
sys.path.insert(0, str(_studio))
iwp = importlib.import_module("install_whisper_prebuilt")
if not hasattr(iwp, "parse_release_checksums"):
pytest.skip("checksum-model symbols not present - check branch", allow_module_level = True)
_A = "0" * 64
_B = "1" * 64
_TAG = "v1.9.1-unsloth.1"
_REPO = "unslothai/whisper.cpp"
def _index(**overrides) -> dict:
payload = {
"schema_version": 1,
"component": "whisper.cpp",
"release_tag": _TAG,
"upstream_tag": "v1.9.1",
"artifacts": {
"whisper-v1.9.1-unsloth.1-linux-x64-cpu.tar.gz": {"sha256": _A},
"whisper-v1.9.1-unsloth.1-linux-x64-cuda12-portable.tar.gz": {"sha256": _B},
},
}
payload.update(overrides)
return payload
# parse_release_checksums / expected_sha256_for are prebuilt_core re-exports;
# their valid/fail-closed matrix is asserted against the real whisper
# descriptor in tests/studio/install/test_prebuilt_core.py. The download-host
# fast-path tests below still route through this module's parse wrapper.
# release tag resolution.
def test_resolve_release_tag_explicit_override_passthrough():
assert iwp.resolve_release_tag(_REPO, published_release_tag = "v1.9.1-unsloth.2") == (
"v1.9.1-unsloth.2"
)
def test_resolve_release_tag_resolves_newest_when_no_override(monkeypatch):
monkeypatch.setattr(iwp, "resolve_newest_release_tag", lambda repo: "v9.9.9-unsloth.9")
assert iwp.resolve_release_tag(_REPO, published_release_tag = None) == "v9.9.9-unsloth.9"
def test_resolve_newest_release_tag_picks_latest_published(monkeypatch):
releases = [
{"tag_name": "v1.9.1-unsloth.1", "published_at": "2026-01-01T00:00:00Z"},
{"tag_name": "v1.9.1-unsloth.3", "published_at": "2026-03-01T00:00:00Z"},
{"tag_name": "v1.9.1-unsloth.2", "published_at": "2026-02-01T00:00:00Z"},
{"tag_name": "draft", "published_at": "2026-09-01T00:00:00Z", "draft": True},
{"tag_name": "pre", "published_at": "2026-09-01T00:00:00Z", "prerelease": True},
]
monkeypatch.setattr(iwp, "fetch_json", lambda url: releases)
assert iwp.resolve_newest_release_tag(_REPO) == "v1.9.1-unsloth.3"
def test_resolve_newest_release_tag_none_published_fails_closed(monkeypatch):
monkeypatch.setattr(iwp, "fetch_json", lambda url: [{"tag_name": "d", "draft": True}])
with pytest.raises(iwp.PrebuiltFallback):
iwp.resolve_newest_release_tag(_REPO)
def test_pins_symbols_are_gone():
# The committed-pins trust model was removed in favour of llama's runtime index.
for gone in ("load_pins", "pins_path", "resolve_expected_sha256", "PINS_FILENAME"):
assert not hasattr(iwp, gone), f"{gone} should have been removed"
# Download-host fast path (resolve + fetch the JSON assets with no GitHub API).
_CPU_ASSET = "whisper-v1.9.1-unsloth.1-linux-x64-cpu.tar.gz"
def _manifest() -> dict:
return {
"schema_version": 1,
"component": "whisper.cpp",
"upstream_tag": "v1.9.1",
"artifacts": [{"asset": _CPU_ASSET, "os": "linux", "arch": "x64", "backend": "cpu"}],
}
def _no_api(monkeypatch):
"""Fail loudly if any code path touches api.github.com."""
def _boom(*a, **k):
raise AssertionError("api.github.com was used on the fast path")
monkeypatch.setattr(iwp, "fetch_json", _boom)
monkeypatch.setattr(iwp, "github_release", _boom)
monkeypatch.setattr(iwp, "fetch_release_bundle", _boom)
def test_fetch_release_for_install_prefers_download_host(monkeypatch):
_no_api(monkeypatch)
monkeypatch.setattr(iwp, "_download_host_latest_release_tag", lambda repo: _TAG)
def _dhj(url):
if url.endswith(iwp.SHA256_ASSET_NAME):
return _index()
if url.endswith(iwp.MANIFEST_ASSET_NAME):
return _manifest()
raise AssertionError(f"unexpected url {url}")
monkeypatch.setattr(iwp, "_download_host_json", _dhj)
bundle, checks = iwp.fetch_release_for_install(_REPO, published_release_tag = None)
assert bundle.release_tag == _TAG
assert checks[_CPU_ASSET] == _A
# asset_urls point at the download host (github.com), not the API.
assert bundle.asset_urls[iwp.SHA256_ASSET_NAME].startswith(
f"https://github.com/{_REPO}/releases/"
)
assert bundle.asset_urls[_CPU_ASSET].startswith(
f"https://github.com/{_REPO}/releases/download/"
)
walked = iwp._fetch_release_candidate(_REPO, _TAG)
assert iwp.SHA256_ASSET_NAME in walked.asset_urls
assert _CPU_ASSET in walked.asset_urls
def test_fetch_release_for_install_explicit_tag_skips_the_head(monkeypatch):
# An explicit tag needs no /releases/latest HEAD: resolving it must not call it.
monkeypatch.setattr(
iwp,
"_download_host_latest_release_tag",
lambda repo: (_ for _ in ()).throw(AssertionError("HEAD used for an explicit tag")),
)
monkeypatch.setattr(
iwp,
"_download_host_json",
lambda url: _index() if url.endswith(iwp.SHA256_ASSET_NAME) else _manifest(),
)
_no_api(monkeypatch)
bundle, checks = iwp.fetch_release_for_install(_REPO, published_release_tag = _TAG)
assert bundle.release_tag == _TAG
def test_fetch_release_for_install_falls_back_to_api(monkeypatch):
# Fast path returns None (e.g. a 404) -> the API path resolves the release.
monkeypatch.setattr(iwp, "_resolve_release_via_download_host", lambda repo, tag: None)
sentinel = iwp.ReleaseBundle(repo = _REPO, release_tag = _TAG, manifest = _manifest(), asset_urls = {})
monkeypatch.setattr(iwp, "resolve_release_tag", lambda repo, *, published_release_tag: _TAG)
monkeypatch.setattr(iwp, "fetch_release_bundle", lambda repo, tag: sentinel)
monkeypatch.setattr(iwp, "fetch_release_checksums", lambda bundle: {_CPU_ASSET: _A})
bundle, checks = iwp.fetch_release_for_install(_REPO, published_release_tag = None)
assert bundle is sentinel
assert checks == {_CPU_ASSET: _A}
def test_resolve_via_download_host_sha_404_returns_none(monkeypatch):
import urllib.error
monkeypatch.setattr(iwp, "_download_host_latest_release_tag", lambda repo: _TAG)
def _dhj(url):
raise urllib.error.HTTPError(url, 404, "not found", {}, None)
monkeypatch.setattr(iwp, "_download_host_json", _dhj)
assert iwp._resolve_release_via_download_host(_REPO, None) is None
def test_resolve_via_download_host_tag_mismatch_returns_none(monkeypatch):
# A checksum index whose self-reported release_tag disagrees is rejected (None).
monkeypatch.setattr(iwp, "_download_host_latest_release_tag", lambda repo: _TAG)
monkeypatch.setattr(
iwp, "_download_host_json", lambda url: _index(release_tag = "v1.9.1-unsloth.2")
)
assert iwp._resolve_release_via_download_host(_REPO, None) is None
def test_download_host_latest_release_tag_parses_redirect(monkeypatch):
class _Resp:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def geturl(self):
return f"https://github.com/{_REPO}/releases/tag/{_TAG}"
class _Opener:
def open(
self,
req,
timeout = None,
):
return _Resp()
monkeypatch.setattr(iwp, "_URL_OPENER", _Opener())
assert iwp._download_host_latest_release_tag(_REPO) == _TAG
def test_download_host_latest_release_tag_404_returns_none(monkeypatch):
import urllib.error
class _Opener:
def open(
self,
req,
timeout = None,
):
raise urllib.error.HTTPError(req.full_url, 404, "nf", {}, None)
monkeypatch.setattr(iwp, "_URL_OPENER", _Opener())
assert iwp._download_host_latest_release_tag(_REPO) is None

View file

@ -254,8 +254,10 @@ def test_legacy_browse_allowlist_includes_linux_run_media_mounts(monkeypatch, tm
)
fake_external_media = SimpleNamespace(
linux_run_media_mount_roots = lambda: [media_root],
macos_volume_roots = lambda: [],
windows_drive_roots = lambda: [],
)
fake_paths.external_media = fake_external_media
fake_studio_db = SimpleNamespace(
list_scan_folders = lambda: [],
contains_sensitive_path_component = studio_db.contains_sensitive_path_component,

View file

@ -119,6 +119,9 @@ def _clean_state(monkeypatch, tmp_path):
monkeypatch.delenv("UNSLOTH_LLAMA_CPP_PATH", raising = False)
# Never hit the network in these tests.
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: None)
# Keep the whisper piggyback out of the llama-only tests: no host probe, no
# whisper phase (test_combined_update.py covers the chained flow).
monkeypatch.setattr(upd, "_whisper_chain_status", lambda **kwargs: None)
yield
freshness.reset_caches()
upd._reset_job_for_tests()

View file

@ -100,6 +100,21 @@ def test_status_response_exposes_update_size_bytes():
assert rl.LlamaUpdateStatusResponse(**without).model_dump()["update_size_bytes"] is None
def test_status_response_exposes_update_component():
model = rl.LlamaUpdateStatusResponse(
supported = True,
update_available = True,
llama_update_available = False,
update_component = "whisper",
whisper = {
"update_available": True,
"installed_tag": "v1",
"latest_tag": "v2",
},
)
assert model.model_dump()["update_component"] == "whisper"
def test_status_handler_runs_off_event_loop(monkeypatch):
seen = {}

View file

@ -21,6 +21,13 @@ from utils import llama_cpp_update as u
from core.inference.llama_cpp import LlamaCppBackend
@pytest.fixture(autouse = True)
def _no_whisper_piggyback(monkeypatch):
# Keep the whisper piggyback probe off the host: these tests exercise the
# llama local-link contract only.
monkeypatch.setattr(u, "_whisper_chain_status", lambda **kwargs: None)
def _make_link(link: Path, target: Path) -> None:
"""Create a directory junction (Windows) / symlink (POSIX); neither needs
elevation."""

View file

@ -34,6 +34,7 @@ def main_module():
def _make_protected_app(
max_bytes: int,
main_module,
request_max_bytes_getter = None,
upload_passthrough_prefixes: tuple = (),
upload_passthrough_max_bytes_getter = None,
):
@ -41,7 +42,13 @@ def _make_protected_app(
app.add_middleware(
main_module.MaxBodyMiddleware,
max_bytes_getter = lambda: max_bytes,
protected_prefixes = ("/v1/chat/completions", "/api/settings", "/api/train"),
protected_prefixes = (
"/v1/chat/completions",
"/api/inference",
"/api/settings",
"/api/train",
),
request_max_bytes_getter = request_max_bytes_getter,
upload_passthrough_prefixes = upload_passthrough_prefixes,
upload_passthrough_max_bytes_getter = upload_passthrough_max_bytes_getter,
)
@ -68,6 +75,10 @@ def _make_protected_app(
total += len(chunk)
return {"ok": True, "chunks": chunks, "total": total}
@app.post("/api/inference/audio/transcribe/raw")
async def transcribe_raw(request: Request):
return {"ok": True, "total": len(await request.body())}
@app.get("/api/train/status")
async def status_get():
return {"ok": True, "get": True}
@ -97,6 +108,43 @@ class TestMaxBodyMiddleware:
assert r.status_code == 200
assert r.json()["unprotected"] is True
def test_route_specific_cap_overrides_default(self, main_module):
app = _make_protected_app(
4096,
main_module,
request_max_bytes_getter = lambda path: (
128 if path.endswith("/transcribe/raw") else 4096
),
)
c = TestClient(app)
rejected = c.post(
"/api/inference/audio/transcribe/raw",
content = b"x" * 129,
)
accepted = c.post(
"/api/inference/audio/transcribe/raw",
content = b"x" * 128,
)
assert rejected.status_code == 413
assert accepted.status_code == 200
assert accepted.json()["total"] == 128
def test_stt_routes_use_audio_specific_caps(self, main_module):
from utils.upload_limits import (
STT_AUDIO_JSON_MAX_BYTES,
STT_AUDIO_RAW_MAX_BYTES,
)
assert (
main_module._get_request_body_max_bytes("/api/inference/audio/transcribe/raw")
== STT_AUDIO_RAW_MAX_BYTES
)
assert (
main_module._get_request_body_max_bytes("/api/inference/audio/transcribe")
== STT_AUDIO_JSON_MAX_BYTES
)
def test_settings_put_body_over_cap_rejected(self, main_module):
app = _make_protected_app(1024, main_module)
c = TestClient(app)

View file

@ -112,13 +112,21 @@ def patch_hub_gguf(monkeypatch):
blob_ids = [local_blob],
gguf_files = {"model-Q4_K_M.gguf": 1000},
)
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: SimpleNamespace(hub_cache = tmp_path),
)
monkeypatch.setattr(
GV,
"list_gguf_variants",
lambda r, hf_token = None: (_variants(), False, [remote_sibling]),
raising = True,
)
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id: [snap])
monkeypatch.setattr(
GV,
"iter_hf_cache_snapshots",
lambda _repo_id, root = None: [snap],
)
monkeypatch.setattr(
CI,
"all_hf_cache_scans",
@ -217,6 +225,10 @@ def test_variant_update_check_detects_companion_only_update(
companion_path: 100,
},
)
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: SimpleNamespace(hub_cache = tmp_path),
)
siblings = [
patch_hub_gguf.sibling("model-Q4_K_M.gguf", 1000, "mainsha"),
patch_hub_gguf.sibling(companion_path, 100, "new-companion"),
@ -227,7 +239,11 @@ def test_variant_update_check_detects_companion_only_update(
lambda r, hf_token = None: (_variants(), has_vision, siblings),
raising = True,
)
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id: [snap])
monkeypatch.setattr(
GV,
"iter_hf_cache_snapshots",
lambda _repo_id, root = None: [snap],
)
monkeypatch.setattr(
CI,
"all_hf_cache_scans",
@ -372,7 +388,7 @@ def test_cached_gguf_scan_keeps_download_timestamp(monkeypatch, tmp_path):
monkeypatch.setattr(
CI,
"_gguf_variant_state_summary",
lambda _repo_id: (False, 0),
lambda _repo_id, **_kwargs: (False, 0),
)
rows = CI._scan_cached_gguf()
@ -384,6 +400,53 @@ def test_cached_gguf_scan_keeps_download_timestamp(monkeypatch, tmp_path):
assert rows[0]["last_modified"] == 5_000.0
def test_cached_model_scan_hides_custom_whisper_repo(monkeypatch, tmp_path):
repo_path = tmp_path / "models--Org--CustomWhisper"
snapshot = repo_path / "snapshots" / ("a" * 40)
snapshot.mkdir(parents = True)
(snapshot / "config.json").write_text(
'{"model_type": "whisper", "architectures": ["WhisperForConditionalGeneration"]}'
)
repo = SimpleNamespace(
repo_id = "Org/CustomWhisper",
repo_type = "model",
repo_path = repo_path,
revisions = [
SimpleNamespace(
files = [
SimpleNamespace(
file_name = "config.json",
size_on_disk = 10,
blob_path = None,
),
SimpleNamespace(
file_name = "model.safetensors",
size_on_disk = 100,
blob_path = str(repo_path / "blobs" / "modelsha"),
),
]
)
],
)
monkeypatch.setattr(
CI,
"all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [repo])],
)
monkeypatch.setattr(
CI,
"_cached_model_snapshot_path",
lambda _repo_path: snapshot,
)
monkeypatch.setattr(
CI.hf_cache_scan,
"is_snapshot_partial",
lambda *args, **kwargs: False,
)
assert CI._scan_cached_models() == []
# ── hf_hub_download_with_xet_fallback force_download bypass (X2/F2) ───
@ -630,7 +693,12 @@ def test_reclaim_replaced_gguf_variant_prunes_old_revision_only(monkeypatch, tmp
invalidated = []
monkeypatch.setattr(CI, "invalidate_hf_cache_scans", lambda: invalidated.append(True))
result = D.reclaim_replaced_gguf_variant(repo_id, "Q4_K_M", frozenset({"NEWsha"}))
result = D.reclaim_replaced_gguf_variant(
repo_id,
"Q4_K_M",
frozenset({"NEWsha"}),
hub_cache = tmp_path,
)
assert result["removed_snapshots"] == 1
assert result["deleted_blobs"] == 1
@ -677,13 +745,75 @@ def test_reclaim_replaced_gguf_variant_keeps_no_symlink_current_file(monkeypatch
monkeypatch.setattr(CI, "all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo_info])])
monkeypatch.setattr(CI, "invalidate_hf_cache_scans", lambda: None)
result = D.reclaim_replaced_gguf_variant(repo_id, "Q4_K_M", frozenset({"REMOTEsha256"}))
result = D.reclaim_replaced_gguf_variant(
repo_id,
"Q4_K_M",
frozenset({"REMOTEsha256"}),
hub_cache = tmp_path,
)
assert snap.exists() is True # the current file must survive
assert result["removed_snapshots"] == 0
assert result["deleted_blobs"] == 0
def test_reclaim_replaced_gguf_variant_only_mutates_worker_cache(monkeypatch, tmp_path):
repo_id = "org/repo-GGUF"
cache_a = tmp_path / "cache-a"
cache_b = tmp_path / "cache-b"
def cached_repo(cache_dir, revision):
repo_path = cache_dir / "models--org--repo-GGUF"
snap = repo_path / "snapshots" / revision / "model-Q4_K_M.gguf"
blob = repo_path / "blobs" / "OLDsha"
snap.parent.mkdir(parents = True, exist_ok = True)
blob.parent.mkdir(parents = True, exist_ok = True)
blob.write_bytes(b"old")
snap.symlink_to(blob)
return (
SimpleNamespace(
repo_id = repo_id,
repo_type = "model",
repo_path = repo_path,
revisions = [
SimpleNamespace(
files = [
SimpleNamespace(
file_name = snap.name,
file_path = str(snap),
blob_path = str(blob),
)
]
)
],
),
snap,
blob,
)
repo_a, snap_a, blob_a = cached_repo(cache_a, "a" * 40)
repo_b, snap_b, blob_b = cached_repo(cache_b, "b" * 40)
monkeypatch.setattr(
CI,
"all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [repo_a]), SimpleNamespace(repos = [repo_b])],
)
monkeypatch.setattr(CI, "invalidate_hf_cache_scans", lambda: None)
result = D.reclaim_replaced_gguf_variant(
repo_id,
"Q4_K_M",
frozenset({"NEWsha"}),
hub_cache = cache_b,
)
assert result["removed_snapshots"] == 1
assert snap_b.exists() is False
assert blob_b.exists() is False
assert snap_a.exists() is True
assert blob_a.exists() is True
def _mmproj_repo(*file_names: str):
return SimpleNamespace(
revisions = [SimpleNamespace(files = [SimpleNamespace(file_name = n) for n in file_names])]

View file

@ -84,7 +84,7 @@ def test_repo_in_any_hf_cache_matches_case_variant_in_legacy_cache(tmp_path, mon
# covers the active cache; discard deletes case-insensitively, so detection must too,
# else a decline deletes a pre-existing user repo).
import utils.paths as paths_pkg
import huggingface_hub.constants as hf_constants
import hub.utils.paths as hub_paths
active = tmp_path / "active"
legacy = tmp_path / "legacy"
@ -96,9 +96,12 @@ def test_repo_in_any_hf_cache_matches_case_variant_in_legacy_cache(tmp_path, mon
# No active-cache variant; case resolution is a no-op here.
monkeypatch.setattr(paths_pkg, "resolve_cached_repo_id_case", lambda name: name)
monkeypatch.setattr(paths_pkg, "legacy_hf_cache_dir", lambda: legacy)
monkeypatch.setattr(paths_pkg, "hf_default_cache_dir", lambda: default)
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(active))
monkeypatch.setattr(hub_paths, "legacy_hf_cache_dir", lambda: legacy)
monkeypatch.setattr(hub_paths, "hf_default_cache_dir", lambda: default)
monkeypatch.setattr(
"utils.hf_cache_settings.known_hf_hub_caches",
lambda: [active],
)
assert models_route._repo_in_any_hf_cache("unsloth/foo") is True
# Absent from every cache -> reported absent.

View file

@ -85,11 +85,19 @@ def _is_embedding_model(*args, **kwargs):
@pytest.fixture
def hf_cache(tmp_path, monkeypatch):
"""Point the HF cache at a fresh temp dir."""
"""Point the HF cache at a fresh temp dir.
get_hf_cache_paths() reads an import-time env snapshot, not live os.environ,
so point it (and thus active_hf_hub_cache + the snapshot lookup's selected
root) at this temp cache too."""
root = tmp_path / "hub"
root.mkdir()
monkeypatch.setenv("HF_HOME", str(tmp_path))
monkeypatch.setenv("HF_HUB_CACHE", str(root))
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: SimpleNamespace(hub_cache = root),
)
return root
@ -198,18 +206,25 @@ def test_snapshot_dir_uses_sentence_transformers_home(tmp_path, monkeypatch):
assert hf_cache_snapshot_dir("org/emb") == snapshot
def test_snapshot_dir_st_home_is_exclusive(tmp_path, monkeypatch):
# With SENTENCE_TRANSFORMERS_HOME set, ST loads only from it, so a model living only under
# HF_HUB_CACHE must not be reported.
def test_snapshot_dir_prefers_selected_cache_over_st_home(tmp_path, monkeypatch):
# The RAG loader passes cache_folder=active_hf_hub_cache(), which overrides
# SENTENCE_TRANSFORMERS_HOME, so the snapshot + offline security lookup must
# search the selected cache even when ST_HOME points elsewhere. Otherwise the
# gate scans a cache the model never loads from and a pickle weight in the
# selected cache slips through.
st_home = tmp_path / "st_home"
st_home.mkdir()
hub = tmp_path / "hub"
hub.mkdir()
selected = tmp_path / "hub"
selected.mkdir()
monkeypatch.setenv("SENTENCE_TRANSFORMERS_HOME", str(st_home))
monkeypatch.setenv("HF_HUB_CACHE", str(hub))
monkeypatch.delenv("HF_HUB_CACHE", raising = False)
monkeypatch.delenv("HF_HOME", raising = False)
_make_cache(hub, "org/emb", {"modules.json": MODULES_JSON}) # only in the HF hub cache
assert hf_cache_snapshot_dir("org/emb") is None
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: SimpleNamespace(hub_cache = selected),
)
snapshot = _make_cache(selected, "org/emb", {"modules.json": MODULES_JSON}) # only in selected
assert hf_cache_snapshot_dir("org/emb") == snapshot
def test_snapshot_is_loadable_with_config_and_weights(hf_cache):

View file

@ -130,6 +130,10 @@ def _symlink_or_skip(link: Path, target: Path) -> None:
def hf_cache(tmp_path, monkeypatch):
"""Point ``huggingface_hub.constants.HF_HUB_CACHE`` at a temp dir."""
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: _types.SimpleNamespace(hub_cache = tmp_path),
)
return tmp_path
@ -227,6 +231,10 @@ class TestGgufVariantFileResolution:
return f"/fake/{repo_id}/{filename}"
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: _types.SimpleNamespace(hub_cache = tmp_path),
)
with (
patch(
"huggingface_hub.list_repo_files",
@ -434,6 +442,40 @@ class TestGgufVariantFileResolution:
assert out == str(snap / "mmproj-F16.gguf")
def test_download_companion_uses_selected_cache_not_import_time_default(
self, monkeypatch, tmp_path
):
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
import_time_cache = tmp_path / "import-time-cache"
selected_cache = tmp_path / "selected-cache"
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(import_time_cache))
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: _types.SimpleNamespace(hub_cache = selected_cache),
)
repo = "unsloth/vision-GGUF"
snap = _build_cache(selected_cache, repo, {"mmproj-F16.gguf": 4})
backend = LlamaCppBackend()
offline_error = type("OfflineModeIsEnabled", (Exception,), {})
def fail_list(*_args, **_kwargs):
raise offline_error("offline")
def fail_download(*_args, **_kwargs):
raise AssertionError("selected-cache companion must not download")
with (
patch("huggingface_hub.list_repo_files", fail_list),
patch(
"core.inference.llama_cpp.hf_hub_download_with_xet_fallback",
fail_download,
),
):
out = backend._download_mmproj(hf_repo = repo)
assert out == str(snap / "mmproj-F16.gguf")
def test_download_includes_uppercase_split_gguf_shards(self, monkeypatch, tmp_path):
backend = LlamaCppBackend()
downloaded: list[str] = []
@ -460,6 +502,10 @@ class TestGgufVariantFileResolution:
return f"/fake/{repo_id}/{filename}"
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
monkeypatch.setattr(
"utils.hf_cache_settings.get_hf_cache_paths",
lambda: _types.SimpleNamespace(hub_cache = tmp_path),
)
with (
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: files),
patch("huggingface_hub.get_paths_info", fake_get_paths_info),

View file

@ -1096,6 +1096,7 @@ def test_build_index_covers_legacy_default_lmstudio_and_custom_roots(monkeypatch
from pathlib import Path
import routes.models as models_route
from utils import paths as upaths
from utils import hf_cache_settings
import storage.studio_db as studio_db
scanned = []
@ -1116,13 +1117,18 @@ def test_build_index_covers_legacy_default_lmstudio_and_custom_roots(monkeypatch
)
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: tmp_path / "active")
monkeypatch.setattr(models_route, "_is_hidden_model", lambda *a, **k: False)
monkeypatch.setattr(
hf_cache_settings,
"known_hf_hub_caches",
lambda: [tmp_path / "active", tmp_path / "previous"],
)
monkeypatch.setattr(upaths, "legacy_hf_cache_dir", lambda: tmp_path / "legacy")
monkeypatch.setattr(upaths, "hf_default_cache_dir", lambda: tmp_path / "default")
monkeypatch.setattr(upaths, "lmstudio_model_dirs", lambda: [tmp_path / "lmstudio"])
monkeypatch.setattr(
studio_db, "list_scan_folders", lambda: [{"path": str(tmp_path / "custom")}]
)
for sub in ("active", "legacy", "default", "lmstudio", "custom"):
for sub in ("active", "previous", "legacy", "default", "lmstudio", "custom"):
(tmp_path / sub).mkdir()
resolver._build_index()
@ -1131,6 +1137,7 @@ def test_build_index_covers_legacy_default_lmstudio_and_custom_roots(monkeypatch
lm = {p for k, p in scanned if k == "lm"}
assert str((tmp_path / "legacy").resolve()) in hf
assert str((tmp_path / "default").resolve()) in hf
assert str((tmp_path / "previous").resolve()) in hf
assert str((tmp_path / "custom").resolve()) in hf
assert str((tmp_path / "lmstudio").resolve()) in lm

View file

@ -183,6 +183,22 @@ def test_loop_short_password_reprompts(monkeypatch):
assert "at least 8 characters" in out
def test_loop_whitespace_only_reprompts(monkeypatch):
ok, applied, out = _run_loop(monkeypatch, _keys(" " * 8, "long-enough-pw", "long-enough-pw"))
assert ok is True
assert applied == ["long-enough-pw"]
assert "contain spaces" in out
def test_loop_password_with_inner_space_reprompts(monkeypatch):
ok, applied, out = _run_loop(
monkeypatch, _keys("has space pw", "long-enough-pw", "long-enough-pw")
)
assert ok is True
assert applied == ["long-enough-pw"]
assert "contain spaces" in out
def test_loop_rejects_current_password(monkeypatch):
ok, applied, out = _run_loop(
monkeypatch, _keys("bootstrap-pw", "fresh-password", "fresh-password")

View file

@ -241,11 +241,15 @@ def test_remote_oversized_jinja_falls_through_to_tokenizer_template(tmp_path, mo
"chat_template.jinja": big_jinja,
"tokenizer_config.json": tokenizer_config,
}
selected_cache = tmp_path / "selected-cache" / "hub"
observed_cache_dirs = []
monkeypatch.setattr("picker.service.resolve_cached_repo_id_case", lambda name: name)
monkeypatch.setattr("picker.service.iter_hf_cache_snapshots", lambda resolved: [])
monkeypatch.setattr("picker.service.active_hf_hub_cache", lambda: str(selected_cache))
def _fake_download(repo_id, rel, **kwargs):
observed_cache_dirs.append(kwargs.get("cache_dir"))
target = files.get(rel)
if target is None:
raise FileNotFoundError(rel)
@ -264,3 +268,5 @@ def test_remote_oversized_jinja_falls_through_to_tokenizer_template(tmp_path, mo
monkeypatch.setattr(huggingface_hub.HfApi, "get_paths_info", _fake_get_paths_info)
assert read_default_chat_template("org/big-jinja-model") == "SMALL_TEMPLATE"
assert observed_cache_dirs
assert set(observed_cache_dirs) == {str(selected_cache)}

View file

@ -5,8 +5,10 @@
and token counting must be serialized (else threads panic "Already borrowed")."""
import os
import sys
import threading
import time
from types import SimpleNamespace
import numpy as np
import pytest
@ -130,6 +132,35 @@ def test_token_counter_enables_parallelism_only_during_call(monkeypatch):
assert os.environ.get("TOKENIZERS_PARALLELISM") == "false" # restored after
def test_sentence_transformer_load_uses_live_cache(monkeypatch, tmp_path):
observed = {}
class FakeSentenceTransformer:
def __init__(self, name, **kwargs):
observed["name"] = name
observed.update(kwargs)
monkeypatch.setitem(
sys.modules,
"sentence_transformers",
SimpleNamespace(SentenceTransformer = FakeSentenceTransformer),
)
monkeypatch.setattr(embeddings, "_install_torchao_stub_once", lambda: None)
monkeypatch.setattr(embeddings, "_guard_model_security", lambda *_a, **_k: None)
monkeypatch.setattr(embeddings, "_device", lambda: "cpu")
monkeypatch.setattr(
"utils.hf_cache_settings.active_hf_hub_cache",
lambda: str(tmp_path / "selected-hub"),
)
embeddings._model = None
embeddings._name = None
embeddings._get("Org/Embedder")
assert observed["name"] == "Org/Embedder"
assert observed["cache_folder"] == str(tmp_path / "selected-hub")
class _SentinelLlamaBackend:
"""Stand-in for LlamaServerBackend; never spawns a real server."""

View file

@ -68,8 +68,6 @@ def test_skips_mtp_drafter_for_main_weights(tmp_path):
def test_prefers_the_complete_snapshot(tmp_path, monkeypatch):
from huggingface_hub import constants as hf_constants
cache = tmp_path / "hub"
snaps = cache / "models--org--repo" / "snapshots"
# Partial older snapshot: one small shard.
@ -78,7 +76,10 @@ def test_prefers_the_complete_snapshot(tmp_path, monkeypatch):
complete_first = _write(snaps / "bbbb" / "model-00001-of-00002-Q4_K_M.gguf", 30)
_write(snaps / "bbbb" / "model-00002-of-00002-Q4_K_M.gguf", 40)
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(cache))
monkeypatch.setattr(
"utils.hf_cache_settings.known_hf_hub_caches",
lambda: [cache],
)
path, total = models_route._resolve_quant_gguf("org/repo", "Q4_K_M", is_local = False)

View file

@ -28,6 +28,9 @@ def _isolate_studio_home(monkeypatch, tmp_path):
def _load_storage_roots():
# Each test models a fresh backend process. The cache resolver intentionally
# snapshots explicit environment variables once per process.
sys.modules.pop("utils.hf_cache_settings", None)
spec = importlib.util.spec_from_file_location("storage_roots_under_test", _STORAGE_ROOTS_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
@ -40,10 +43,10 @@ def _clear_hf_env(monkeypatch):
def test_custom_hf_home_seeds_hub_and_xet(monkeypatch, tmp_path):
sr = _load_storage_roots()
_clear_hf_env(monkeypatch)
custom = tmp_path / "shared" / "huggingface"
monkeypatch.setenv("HF_HOME", str(custom))
sr = _load_storage_roots()
sr._setup_cache_env()
@ -54,9 +57,9 @@ def test_custom_hf_home_seeds_hub_and_xet(monkeypatch, tmp_path):
def test_default_when_hf_home_unset(monkeypatch, tmp_path):
sr = _load_storage_roots()
_clear_hf_env(monkeypatch)
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg"))
sr = _load_storage_roots()
sr._setup_cache_env()
@ -67,11 +70,11 @@ def test_default_when_hf_home_unset(monkeypatch, tmp_path):
def test_explicit_hub_cache_is_not_overridden(monkeypatch, tmp_path):
sr = _load_storage_roots()
_clear_hf_env(monkeypatch)
monkeypatch.setenv("HF_HOME", str(tmp_path / "home"))
explicit = tmp_path / "explicit" / "hub"
monkeypatch.setenv("HF_HUB_CACHE", str(explicit))
sr = _load_storage_roots()
sr._setup_cache_env()
@ -81,11 +84,11 @@ def test_explicit_hub_cache_is_not_overridden(monkeypatch, tmp_path):
def test_legacy_huggingface_hub_cache_alias_is_honored(monkeypatch, tmp_path):
sr = _load_storage_roots()
_clear_hf_env(monkeypatch)
monkeypatch.setenv("HF_HOME", str(tmp_path / "home"))
legacy = tmp_path / "legacy" / "hub"
monkeypatch.setenv("HUGGINGFACE_HUB_CACHE", str(legacy))
sr = _load_storage_roots()
sr._setup_cache_env()
@ -96,15 +99,16 @@ def test_legacy_huggingface_hub_cache_alias_is_honored(monkeypatch, tmp_path):
def test_whitespace_hf_home_falls_back_to_default(monkeypatch, tmp_path):
# A blank/whitespace HF_HOME must not become " /hub"; fall back to default.
sr = _load_storage_roots()
_clear_hf_env(monkeypatch)
monkeypatch.setenv("HF_HOME", " ")
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg"))
sr = _load_storage_roots()
sr._setup_cache_env()
import os
assert os.environ["HF_HOME"] == str(tmp_path / "xdg" / "huggingface")
assert os.environ["HF_HUB_CACHE"] == str(tmp_path / "xdg" / "huggingface" / "hub")
@ -114,9 +118,9 @@ def test_unwritable_hf_home_does_not_crash(monkeypatch, tmp_path):
blocker = tmp_path / "blocker"
blocker.write_text("not a dir")
unwritable = blocker / "hf"
sr = _load_storage_roots()
_clear_hf_env(monkeypatch)
monkeypatch.setenv("HF_HOME", str(unwritable))
sr = _load_storage_roots()
sr._setup_cache_env() # must not raise

View file

@ -0,0 +1,168 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""The /audio/stt/download route must validate a custom Transformers repo before
snapshot_download pulls it into the shared HF cache.
Regression for a Codex finding: the Transformers engine accepts arbitrary
`owner/model` repos, so an authenticated caller could make Studio download a
large non-STT repository before load-time validation ever ran. Whisper-
compatibility is now enforced (metadata-only, no weights) before the background
download starts. The GGUF engine only accepts curated ids, so it is not gated.
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
import pytest
from fastapi import HTTPException
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
import core.inference.stt_ggml_sidecar as ggml_module # noqa: E402
import core.inference.stt_sidecar as stt_module # noqa: E402
import routes.inference as ri # noqa: E402
from core.inference.stt_sidecar import SttModelCompatibilityError # noqa: E402
from models.inference import SttLoadRequest # noqa: E402
def _run(coro):
return asyncio.run(coro)
def test_custom_non_whisper_repo_is_rejected_before_download(monkeypatch):
started: list = []
validated: list = []
def fake_validate(model, hf_token = None):
validated.append(model)
raise SttModelCompatibilityError(
f"STT model '{model}' is not a compatible Transformers Whisper model."
)
def fake_download(model, hf_token = None):
started.append(model)
monkeypatch.setattr(stt_module, "validate_remote_model", fake_validate)
monkeypatch.setattr(stt_module, "start_model_download", fake_download)
with pytest.raises(HTTPException) as excinfo:
_run(
ri.stt_download(
SttLoadRequest(model = "owner/chat-model", engine = "transformers"),
current_subject = "tester",
hf_token = None,
)
)
assert excinfo.value.status_code == 422
assert validated == ["owner/chat-model"]
# The download never starts for a repo that failed the Whisper check.
assert started == []
def test_validated_transformers_repo_downloads(monkeypatch):
started: list = []
revision = "a" * 40
monkeypatch.setattr(
stt_module,
"validate_remote_model",
lambda model, hf_token = None: {"model": model, "revision": revision},
)
monkeypatch.setattr(
stt_module,
"start_model_download",
lambda model, hf_token = None, revision = None: started.append((model, revision)),
)
monkeypatch.setattr(stt_module, "download_status", lambda: {"downloading": True})
resp = _run(
ri.stt_download(
SttLoadRequest(model = "owner/real-whisper", engine = "transformers"),
current_subject = "tester",
hf_token = None,
)
)
assert resp.status_code == 200
assert started == [("owner/real-whisper", revision)]
def test_gguf_engine_skips_the_transformers_repo_check(monkeypatch):
started: list = []
def fail_if_called(model, hf_token = None):
raise AssertionError("GGUF downloads must not run the Transformers repo check")
# whisper-server present, so the GGUF request stays on the GGUF engine.
monkeypatch.setattr(ggml_module, "is_available", lambda: True)
monkeypatch.setattr(stt_module, "validate_remote_model", fail_if_called)
monkeypatch.setattr(
ggml_module, "start_model_download", lambda model, hf_token = None: started.append(model)
)
monkeypatch.setattr(ggml_module, "download_status", lambda: {"downloading": True})
resp = _run(
ri.stt_download(
SttLoadRequest(model = "small", engine = "gguf"),
current_subject = "tester",
hf_token = None,
)
)
assert resp.status_code == 200
assert started == ["small"]
def test_resolve_serving_stt_engine_falls_back_when_whisper_server_absent(monkeypatch):
# A curated GGUF request downgrades to Transformers when whisper-server is not
# installed (both engines serve curated ids), but stays GGUF when it is.
monkeypatch.setattr(ggml_module, "is_available", lambda: False)
assert ri._resolve_serving_stt_engine("gguf") == "transformers"
monkeypatch.setattr(ggml_module, "is_available", lambda: True)
assert ri._resolve_serving_stt_engine("gguf") == "gguf"
# Transformers is unaffected by whisper-server availability.
monkeypatch.setattr(ggml_module, "is_available", lambda: False)
assert ri._resolve_serving_stt_engine("transformers") == "transformers"
def test_gguf_download_falls_back_to_transformers_when_server_absent(monkeypatch):
"""Selecting the default curated model on a host without whisper-server must
download through the Transformers engine, not 501/dead-end on GGUF."""
gguf_started: list = []
tf_started: list = []
monkeypatch.setattr(ggml_module, "is_available", lambda: False) # no whisper-server
# validate_remote_model no-ops curated ids in production; keep it a no-op here.
monkeypatch.setattr(
stt_module, "validate_remote_model", lambda model, hf_token = None: {"model": model}
)
monkeypatch.setattr(
stt_module,
"start_model_download",
lambda model, hf_token = None, revision = None: tf_started.append(model),
)
monkeypatch.setattr(stt_module, "download_status", lambda: {"downloading": True})
monkeypatch.setattr(
ggml_module,
"start_model_download",
lambda model, hf_token = None: gguf_started.append(model),
)
resp = _run(
ri.stt_download(
SttLoadRequest(model = "small", engine = "gguf"),
current_subject = "tester",
hf_token = None,
)
)
assert resp.status_code == 200
assert tf_started == ["small"] # served by Transformers instead of dead-ending on GGUF
assert gguf_started == []

View file

@ -0,0 +1,780 @@
# 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 http.server
import io
import json
import os
import sys
import threading
import time
import wave
from pathlib import Path
import numpy as np
import pytest
import core.inference.stt_ggml_sidecar as ggml_module
from core.inference.stt_ggml_sidecar import (
DEFAULT_GGML_STT_MODEL,
GGML_STT_MODELS,
GGML_STT_REPOS,
GgmlSttSidecar,
SttEngineUnavailableError,
find_whisper_server_binary,
resolve_ggml_model_id,
)
from core.inference.stt_sidecar import (
SttLanguageError,
SttLoadCancelledError,
SttModelIdError,
SttModelNotDownloadedError,
SttUnavailableError,
)
@pytest.fixture(autouse = True)
def isolate_runtime_and_stub_audio_decoder(monkeypatch, tmp_path):
"""Unit tests exercise orchestration, not PyAV container parsing."""
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path / "studio"))
monkeypatch.delenv("WHISPER_SERVER_PATH", raising = False)
monkeypatch.delenv("UNSLOTH_WHISPER_CPP_PATH", raising = False)
monkeypatch.setenv("PATH", "")
monkeypatch.setattr(
ggml_module,
"_decode_audio_bounded",
lambda audio: np.zeros(16000, dtype = np.float32),
)
# ---------------------------------------------------------------------------
# Model id resolution
# ---------------------------------------------------------------------------
def test_curated_ids_resolve():
for model_id in GGML_STT_MODELS:
assert resolve_ggml_model_id(model_id) == model_id
def test_default_model_resolves_from_none_and_blank():
assert resolve_ggml_model_id(None) == DEFAULT_GGML_STT_MODEL
assert resolve_ggml_model_id(" ") == DEFAULT_GGML_STT_MODEL
def test_custom_repo_ids_are_rejected():
with pytest.raises(SttModelIdError):
resolve_ggml_model_id("owner/model")
with pytest.raises(SttModelIdError):
resolve_ggml_model_id("large-v2")
def test_curated_ids_mirror_transformers_sidecar():
from core.inference.stt_sidecar import STT_MODELS
assert list(GGML_STT_MODELS.keys()) == list(STT_MODELS.keys())
def test_curated_filenames_match_repo_naming():
# unslothai/whisper-<id>-GGUF hosts whisper-<id>.bin; keep the download
# filename in lockstep with the repo so it resolves instead of 404ing.
for model_id, repo in GGML_STT_REPOS.items():
expected = repo.split("/", 1)[1].removesuffix("-GGUF") + ".bin"
assert GGML_STT_MODELS[model_id] == expected
# ---------------------------------------------------------------------------
# Binary discovery
# ---------------------------------------------------------------------------
def test_env_binary_override_wins(monkeypatch, tmp_path):
binary = tmp_path / "whisper-server"
binary.write_text("#!/bin/sh\n")
binary.chmod(0o755) # find_whisper_server_binary requires an executable
monkeypatch.setenv("WHISPER_SERVER_PATH", str(binary))
assert find_whisper_server_binary() == str(binary)
def test_env_dir_override_scans_layouts(monkeypatch, tmp_path):
monkeypatch.delenv("WHISPER_SERVER_PATH", raising = False)
build_bin = tmp_path / "build" / "bin"
build_bin.mkdir(parents = True)
binary = build_bin / "whisper-server"
binary.write_text("#!/bin/sh\n")
binary.chmod(0o755) # find_whisper_server_binary requires an executable
monkeypatch.setenv("UNSLOTH_WHISPER_CPP_PATH", str(tmp_path))
assert find_whisper_server_binary() == str(binary)
def test_missing_binary_reports_unavailable(monkeypatch, tmp_path):
monkeypatch.delenv("WHISPER_SERVER_PATH", raising = False)
monkeypatch.setenv("UNSLOTH_WHISPER_CPP_PATH", str(tmp_path / "nope"))
monkeypatch.setattr(ggml_module, "_managed_whisper_cpp_dir", lambda: tmp_path / "gone")
monkeypatch.setattr(ggml_module.shutil, "which", lambda name: None)
assert find_whisper_server_binary() is None
assert not ggml_module.is_available()
with pytest.raises(SttEngineUnavailableError):
ggml_module.ensure_engine_available()
def test_non_executable_binary_is_not_runnable(monkeypatch, tmp_path):
if sys.platform == "win32":
pytest.skip("X_OK is an existence check on Windows")
binary = tmp_path / "whisper-server"
binary.write_text("#!/bin/sh\n") # written but not chmod +x
monkeypatch.setenv("WHISPER_SERVER_PATH", str(binary))
monkeypatch.setattr(ggml_module.shutil, "which", lambda name: None)
assert find_whisper_server_binary() is None
# ---------------------------------------------------------------------------
# Slim-install launch guard
# ---------------------------------------------------------------------------
def _slim_install(
tmp_path,
*,
install_kind = "slim",
with_ggml = True,
linked_libraries = None,
backend = "cpu",
linked_runtime_directories = None,
runtime_wiring_version = None,
) -> str:
"""A managed-looking install tree: marker at the root, server in build/bin."""
install_dir = tmp_path / "whisper.cpp"
bin_dir = install_dir / "build" / "bin"
bin_dir.mkdir(parents = True)
binary = bin_dir / "whisper-server"
binary.write_text("#!/bin/sh\n")
binary.chmod(0o755)
marker: dict = {
"schema_version": 1,
"component": "whisper.cpp",
"release_tag": "v1.9.1-unsloth.1",
"backend": backend,
"paired_llama_tag": "b10069-mix-fb3d4ca",
}
if install_kind is not None:
marker["install_kind"] = install_kind
if linked_libraries is not None:
marker["linked_libraries"] = linked_libraries
if linked_runtime_directories is not None:
marker["linked_runtime_directories"] = linked_runtime_directories
for name in linked_runtime_directories:
catalog = bin_dir / name
catalog.mkdir()
(catalog / "kernel.dat").write_bytes(b"kernel")
if runtime_wiring_version is not None:
marker["runtime_wiring_version"] = runtime_wiring_version
(install_dir / "UNSLOTH_WHISPER_PREBUILT_INFO.json").write_text(json.dumps(marker))
if with_ggml:
names = (
("ggml.dll", "ggml-base.dll")
if sys.platform == "win32"
else ("libggml.so.0", "libggml-base.so.0")
)
for name in names:
(bin_dir / name).write_bytes(b"ggml")
return str(binary)
def test_slim_guard_flags_missing_ggml_links(monkeypatch, tmp_path):
# A slim marker whose linked ggml runtime is gone must read as engine
# unavailable (reinstall), never crash into a server launch.
binary = _slim_install(tmp_path, with_ggml = False)
assert ggml_module.slim_runtime_intact(binary) is False
monkeypatch.setattr(ggml_module, "find_whisper_server_binary", lambda: binary)
assert not ggml_module.is_available()
with pytest.raises(SttEngineUnavailableError, match = "ggml"):
ggml_module.ensure_engine_available()
def test_slim_guard_passes_with_links_in_place(monkeypatch, tmp_path):
names = ["libggml.so.0", "libggml-base.so.0"]
binary = _slim_install(tmp_path, with_ggml = True, linked_libraries = names)
assert ggml_module.slim_runtime_intact(binary) is True
monkeypatch.setattr(ggml_module, "find_whisper_server_binary", lambda: binary)
assert ggml_module.ensure_engine_available() == binary
def test_slim_guard_verifies_the_marker_linked_libraries(monkeypatch, tmp_path):
# New markers record the exact wired filenames; one missing name flips the
# install to unavailable even when the legacy core ggml names are present.
names = ["libggml.dylib", "libggml-base.dylib", "libggml-metal.dylib"]
binary = _slim_install(tmp_path, with_ggml = True, linked_libraries = names)
bin_dir = Path(binary).parent
for name in names[:-1]:
(bin_dir / name).write_bytes(b"ggml")
assert ggml_module.slim_runtime_intact(binary) is False # metal dylib absent
(bin_dir / names[-1]).write_bytes(b"ggml")
assert ggml_module.slim_runtime_intact(binary) is True
monkeypatch.setattr(ggml_module, "find_whisper_server_binary", lambda: binary)
assert ggml_module.ensure_engine_available() == binary
def test_slim_guard_malformed_authoritative_marker_fails_closed(tmp_path):
for bad in ("not-a-list", [], [1, 2]):
root = tmp_path / f"case_{type(bad).__name__}_{len(str(bad))}"
root.mkdir()
binary = _slim_install(root, with_ggml = True, linked_libraries = bad)
assert ggml_module.slim_runtime_intact(binary) is False
def test_slim_guard_prefers_authoritative_root_marker(tmp_path):
names = ["libggml.so.0", "libggml-base.so.0"]
binary = _slim_install(tmp_path, with_ggml = True, linked_libraries = names)
packaging_marker = Path(binary).parent / "UNSLOTH_WHISPER_PREBUILT_INFO.json"
packaging_marker.write_text(json.dumps({"backend": "slim", "release_tag": "packaging"}))
assert ggml_module._whisper_install_marker(binary)["install_kind"] == "slim"
assert ggml_module.slim_runtime_intact(binary) is True
def test_slim_guard_rejects_invalid_root_even_with_inner_marker(tmp_path):
binary = _slim_install(tmp_path, with_ggml = True, linked_libraries = ["libggml.so.0"])
root_marker = Path(binary).parents[2] / "UNSLOTH_WHISPER_PREBUILT_INFO.json"
root_marker.write_text("not json")
(Path(binary).parent / root_marker.name).write_text(json.dumps({"backend": "slim"}))
assert ggml_module.slim_runtime_intact(binary) is False
def test_slim_guard_rejects_missing_rocm_catalog(tmp_path):
names = ["libggml.so.0", "libggml-base.so.0", "libggml-hip.so"]
binary = _slim_install(
tmp_path,
linked_libraries = names,
backend = "rocm",
linked_runtime_directories = ["hipblaslt", "rocblas"],
runtime_wiring_version = 2,
)
bin_dir = Path(binary).parent
(bin_dir / "libggml-hip.so").write_bytes(b"ggml")
assert ggml_module.slim_runtime_intact(binary) is True
(bin_dir / "rocblas" / "kernel.dat").unlink()
assert ggml_module.slim_runtime_intact(binary) is False
def test_slim_guard_accepts_windows_rocm_dll_overlay(monkeypatch, tmp_path):
monkeypatch.setattr(ggml_module.sys, "platform", "win32")
names = ["ggml.dll", "ggml-base.dll", "ggml-hip.dll", "amdhip64.dll"]
binary = _slim_install(
tmp_path,
linked_libraries = names,
backend = "rocm",
linked_runtime_directories = [],
runtime_wiring_version = 2,
)
for name in names:
(Path(binary).parent / name).write_bytes(b"dll")
assert ggml_module.slim_runtime_intact(binary) is True
def test_slim_guard_ignores_fat_and_markerless_installs(tmp_path):
# Fat installs carry their own ggml; no marker means source/custom build.
fat = _slim_install(tmp_path / "fat", install_kind = None, with_ggml = False)
assert ggml_module.slim_runtime_intact(fat) is True
bare = tmp_path / "bare" / "whisper-server"
bare.parent.mkdir(parents = True)
bare.write_text("#!/bin/sh\n")
assert ggml_module.slim_runtime_intact(str(bare)) is True
# ---------------------------------------------------------------------------
# whisper-server child-process environment
# ---------------------------------------------------------------------------
def _loader_path_var() -> str:
return {"win32": "PATH", "darwin": "DYLD_LIBRARY_PATH"}.get(sys.platform, "LD_LIBRARY_PATH")
def test_child_env_scrubs_secrets_and_adds_lib_dir(monkeypatch, tmp_path):
monkeypatch.setenv("HF_TOKEN", "secret-token") # exact name
monkeypatch.setenv("MY_API_KEY", "nope") # marker substring
monkeypatch.setenv("HTTPS_PROXY", "http://u:p@px:8080") # url-name
monkeypatch.setenv("SOME_REMOTE", "https://u:pw@host/repo") # url-userinfo value
monkeypatch.setenv("STT_KEEPME", "keep") # benign
binary = tmp_path / "whisper-server"
binary.write_text("#!/bin/sh\n")
env = ggml_module._whisper_server_child_env(str(binary))
for scrubbed in ("HF_TOKEN", "MY_API_KEY", "HTTPS_PROXY", "SOME_REMOTE"):
assert scrubbed not in env
assert env.get("STT_KEEPME") == "keep"
assert str(tmp_path.resolve()) in env[_loader_path_var()].split(os.pathsep)
def test_child_env_isolates_home_and_cred_locations(monkeypatch, tmp_path):
# The downloaded server must not see the real home (token caches live
# there) nor explicit cred-store pointers like HF_HOME / NETRC.
monkeypatch.setenv("HOME", "/real/home")
monkeypatch.setenv("HF_HOME", "/real/hf")
monkeypatch.setenv("NETRC", "/real/.netrc")
monkeypatch.setattr(ggml_module, "_managed_whisper_cpp_dir", lambda: tmp_path / "managed")
binary = tmp_path / "whisper-server"
binary.write_text("#!/bin/sh\n")
env = ggml_module._whisper_server_child_env(str(binary))
assert env["HOME"] == str(tmp_path / "managed" / ".child_home")
assert "HF_HOME" not in env
assert "NETRC" not in env
assert (tmp_path / "managed" / ".child_home").is_dir()
def test_child_env_wsl_rocm_prepends_system_hip(monkeypatch, tmp_path):
if sys.platform != "linux":
pytest.skip("WSL ROCm library precedence is Linux-only")
rocm = tmp_path / "rocm-lib"
rocm.mkdir()
bindir = tmp_path / "bin"
bindir.mkdir()
binary = bindir / "whisper-server"
binary.write_text("#!/bin/sh\n")
monkeypatch.setattr(ggml_module, "_wsl_system_rocm_lib_dirs", lambda: [str(rocm)])
env = ggml_module._whisper_server_child_env(str(binary))
parts = env["LD_LIBRARY_PATH"].split(os.pathsep)
assert parts[0] == str(rocm.resolve()) # system HIP wins
assert str(bindir.resolve()) in parts # bundle libs still present
assert env.get("HSA_ENABLE_DXG_DETECTION") == "1"
def test_child_env_adds_cuda_runtime_dirs_for_cuda_bundle(monkeypatch, tmp_path):
# Versioned CUDA backend modules are valid too. They still need the
# CUDA-from-PyTorch wheel dirs for libcudart/libcublas at launch.
if sys.platform == "darwin":
pytest.skip("no CUDA on macOS")
import utils.prebuilt.runtime_libs as rl
bindir = tmp_path / "bin"
bindir.mkdir()
(bindir / "whisper-server").write_text("#!/bin/sh\n")
module_name = "ggml-cuda.dll" if sys.platform == "win32" else "libggml-cuda.so.0"
(bindir / module_name).write_text("")
cuda_dir = tmp_path / "nvidia" / "cuda_runtime" / "lib"
cuda_dir.mkdir(parents = True)
monkeypatch.setattr(rl, "python_runtime_dirs", lambda: [str(cuda_dir)])
env = ggml_module._whisper_server_child_env(str(bindir / "whisper-server"))
parts = env[_loader_path_var()].split(os.pathsep)
assert str(bindir.resolve()) in parts
assert str(cuda_dir.resolve()) in parts
assert parts.index(str(bindir.resolve())) < parts.index(str(cuda_dir.resolve()))
def test_child_env_omits_cuda_runtime_dirs_for_cpu_bundle(monkeypatch, tmp_path):
# No libggml-cuda.so beside the binary -> a static CPU/Metal bundle -> the CUDA
# wheel discovery must not run and must not touch the loader path.
if sys.platform == "darwin":
pytest.skip("no CUDA on macOS")
import utils.prebuilt.runtime_libs as rl
bindir = tmp_path / "bin"
bindir.mkdir()
(bindir / "whisper-server").write_text("#!/bin/sh\n")
cuda_dir = tmp_path / "nvidia" / "cuda_runtime" / "lib"
cuda_dir.mkdir(parents = True)
called = {"n": 0}
def _fake_dirs():
called["n"] += 1
return [str(cuda_dir)]
monkeypatch.setattr(rl, "python_runtime_dirs", _fake_dirs)
env = ggml_module._whisper_server_child_env(str(bindir / "whisper-server"))
parts = env[_loader_path_var()].split(os.pathsep)
assert str(cuda_dir.resolve()) not in parts
assert called["n"] == 0
def test_engine_unavailable_is_stt_unavailable():
# Routes map SttUnavailableError to HTTP 501; the engine error must share it.
assert issubclass(SttEngineUnavailableError, SttUnavailableError)
# ---------------------------------------------------------------------------
# WAV packaging
# ---------------------------------------------------------------------------
def test_pcm_to_wav_bytes_shape_and_rate():
pcm = np.zeros(3200, dtype = np.float32)
data = ggml_module._pcm_to_wav_bytes(pcm)
with wave.open(io.BytesIO(data)) as w:
assert w.getnchannels() == 1
assert w.getsampwidth() == 2
assert w.getframerate() == 16000
assert w.getnframes() == 3200
def test_pcm_to_wav_bytes_clips_out_of_range():
pcm = np.array([2.0, -2.0], dtype = np.float32)
data = ggml_module._pcm_to_wav_bytes(pcm)
with wave.open(io.BytesIO(data)) as w:
frames = np.frombuffer(w.readframes(2), dtype = "<i2")
assert frames[0] == 32767
assert frames[1] == -32767
# ---------------------------------------------------------------------------
# Sidecar orchestration
# ---------------------------------------------------------------------------
def _available(monkeypatch):
monkeypatch.setattr(ggml_module, "find_whisper_server_binary", lambda: "/bin/echo")
def test_transcribe_requires_engine(monkeypatch):
monkeypatch.setattr(ggml_module, "find_whisper_server_binary", lambda: None)
sidecar = GgmlSttSidecar()
with pytest.raises(SttEngineUnavailableError):
sidecar.transcribe(b"RIFF")
def test_transcribe_rejects_unknown_language(monkeypatch):
_available(monkeypatch)
sidecar = GgmlSttSidecar()
with pytest.raises(SttLanguageError):
sidecar.transcribe(b"RIFF", model = "small", language = "xx-QQ")
def test_load_requires_downloaded_model(monkeypatch):
_available(monkeypatch)
monkeypatch.setattr(ggml_module, "_cached_model_path", lambda model_id: None)
sidecar = GgmlSttSidecar()
with pytest.raises(SttModelNotDownloadedError):
sidecar.load("small")
def test_unloaded_sidecar_reports_nothing_resident():
sidecar = GgmlSttSidecar()
assert sidecar.loaded_model is None
assert sidecar.device is None
assert sidecar.is_loading() is False
sidecar.unload() # no-op, must not raise
def test_update_maintenance_unloads_and_blocks_new_loads(monkeypatch):
class FakeProcess:
pid = 4242
def __init__(self):
self.running = True
def poll(self):
return None if self.running else 0
def terminate(self):
self.running = False
def wait(self, timeout = None):
return 0
monkeypatch.setattr(ggml_module, "forget_pid", lambda _pid: None)
sidecar = GgmlSttSidecar()
sidecar._process = FakeProcess()
sidecar._model_id = "small"
with sidecar.update_maintenance() as model_was_active:
assert model_was_active is True
assert sidecar.loaded_model is None
with pytest.raises(SttEngineUnavailableError, match = "being updated"):
sidecar.load("small")
assert sidecar._update_in_progress is False
def test_server_pid_is_tracked_for_parent_lifetime(monkeypatch):
# The spawned server must be adopted for the terminate_all backstop and
# forgotten once this sidecar has reaped it.
_available(monkeypatch)
monkeypatch.setattr(ggml_module, "_cached_model_path", lambda model_id: "/tmp/ggml.bin")
class FakeProcess:
pid = 4242
def __init__(self, *args, **kwargs):
self.terminated = False
def poll(self):
return 1 if self.terminated else None
def terminate(self):
self.terminated = True
def wait(self, timeout = None):
return 0
events = []
monkeypatch.setattr(ggml_module.subprocess, "Popen", FakeProcess)
monkeypatch.setattr(ggml_module, "adopt_pid", lambda pid: events.append(("adopt", pid)))
monkeypatch.setattr(ggml_module, "forget_pid", lambda pid: events.append(("forget", pid)))
monkeypatch.setattr(
GgmlSttSidecar,
"_wait_for_server",
staticmethod(lambda process, port, cancel_event = None: None),
)
sidecar = GgmlSttSidecar()
sidecar.load("small")
assert events == [("adopt", 4242)]
sidecar.unload()
assert events == [("adopt", 4242), ("forget", 4242)]
def test_training_forces_whisper_server_off_gpu(monkeypatch):
# Mirror the Transformers sidecar: keep whisper.cpp on CPU during training
# so a mid-training dictation cannot reclaim the VRAM training just freed.
_available(monkeypatch)
monkeypatch.setattr(ggml_module, "_cached_model_path", lambda model_id: "/tmp/ggml.bin")
commands: list[list[str]] = []
class FakeProcess:
pid = 4242
def __init__(self, command, *args, **kwargs):
commands.append(command)
def poll(self):
return None
def terminate(self):
pass
def wait(self, timeout = None):
return 0
monkeypatch.setattr(ggml_module.subprocess, "Popen", FakeProcess)
monkeypatch.setattr(ggml_module, "adopt_pid", lambda pid: None)
monkeypatch.setattr(ggml_module, "forget_pid", lambda pid: None)
monkeypatch.setattr(
GgmlSttSidecar,
"_wait_for_server",
staticmethod(lambda process, port, cancel_event = None: None),
)
monkeypatch.setattr(ggml_module, "_training_active", lambda: False)
idle = GgmlSttSidecar()
idle.load("small")
assert "--no-gpu" not in commands[0]
assert idle.is_loading() is False
idle.unload()
monkeypatch.setattr(ggml_module, "_training_active", lambda: True)
training = GgmlSttSidecar()
training.load("small")
assert "--no-gpu" in commands[1]
training.unload()
def test_cpu_root_marker_forces_no_gpu_despite_inner_packaging_marker(monkeypatch, tmp_path):
names = ["libggml.so.0", "libggml-base.so.0"]
binary = _slim_install(tmp_path, with_ggml = True, linked_libraries = names)
(Path(binary).parent / "UNSLOTH_WHISPER_PREBUILT_INFO.json").write_text(
json.dumps({"backend": "slim"})
)
monkeypatch.setattr(ggml_module, "find_whisper_server_binary", lambda: binary)
monkeypatch.setattr(ggml_module, "_cached_model_path", lambda model_id: "/tmp/ggml.bin")
commands: list[list[str]] = []
class FakeProcess:
pid = 4244
def __init__(self, command, *args, **kwargs):
commands.append(command)
def poll(self):
return None
def terminate(self):
pass
def wait(self, timeout = None):
return 0
monkeypatch.setattr(ggml_module.subprocess, "Popen", FakeProcess)
monkeypatch.setattr(ggml_module, "adopt_pid", lambda pid: None)
monkeypatch.setattr(ggml_module, "forget_pid", lambda pid: None)
monkeypatch.setattr(ggml_module, "_training_active", lambda: False)
monkeypatch.setattr(
GgmlSttSidecar,
"_wait_for_server",
staticmethod(lambda process, port, cancel_event = None: None),
)
sidecar = GgmlSttSidecar()
sidecar.load("small")
assert "--no-gpu" in commands[0]
sidecar.unload()
def test_startup_is_cancellable_before_training(monkeypatch):
# A whisper-server still binding its (Metal/CUDA) backend must be preemptible
# so training coordination can stop it before admitting the run, instead of
# racing an allocating subprocess.
_available(monkeypatch)
monkeypatch.setattr(ggml_module, "_cached_model_path", lambda model_id: "/tmp/ggml.bin")
class FakeProcess:
pid = 4243
def __init__(self, *args, **kwargs):
self.terminated = False
self.killed = False
def poll(self):
return -15 if (self.terminated or self.killed) else None
def terminate(self):
self.terminated = True
def kill(self):
self.killed = True
def wait(self, timeout = None):
return 0
monkeypatch.setattr(ggml_module.subprocess, "Popen", FakeProcess)
monkeypatch.setattr(ggml_module, "adopt_pid", lambda pid: None)
monkeypatch.setattr(ggml_module, "forget_pid", lambda pid: None)
# The server never reports ready, so _wait_for_server loops until cancelled.
def never_ready(req, timeout = None):
raise OSError("connection refused")
monkeypatch.setattr(ggml_module.urllib.request, "urlopen", never_ready)
sidecar = GgmlSttSidecar()
result: dict = {}
def _load():
try:
sidecar.load("small")
result["ok"] = True
except Exception as exc: # noqa: BLE001 - recorded for the assertion below
result["error"] = exc
thread = threading.Thread(target = _load)
thread.start()
try:
deadline = time.monotonic() + 5
while time.monotonic() < deadline and not sidecar.is_loading():
time.sleep(0.01)
assert sidecar.is_loading() is True
assert sidecar.cancel_pending_load() is True
# Blocks until the cancelled startup has been reaped and the lock freed.
sidecar.wait_for_load_to_settle()
finally:
thread.join(timeout = 5)
assert thread.is_alive() is False
assert isinstance(result.get("error"), SttLoadCancelledError)
assert sidecar.is_loading() is False
assert sidecar.loaded_model is None
class _FakeWhisperHandler(http.server.BaseHTTPRequestHandler):
"""Stands in for whisper-server's /inference endpoint."""
response_text = "Hello world.\n Second line."
def do_POST(self):
length = int(self.headers.get("Content-Length", "0"))
self.rfile.read(length)
body = json.dumps({"text": self.response_text}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
@pytest.fixture()
def fake_whisper_server():
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _FakeWhisperHandler)
thread = threading.Thread(target = server.serve_forever, daemon = True)
thread.start()
yield server.server_address[1]
server.shutdown()
def test_transcribe_joins_segments_one_line(monkeypatch, fake_whisper_server):
_available(monkeypatch)
monkeypatch.setattr(ggml_module, "_cached_model_path", lambda model_id: "/tmp/ggml.bin")
sidecar = GgmlSttSidecar()
def fake_load(model = None):
sidecar._port = fake_whisper_server
sidecar._model_id = ggml_module.resolve_ggml_model_id(model)
monkeypatch.setattr(sidecar, "load", fake_load)
result = sidecar.transcribe(b"RIFF", model = "small", language = "en", fast = True)
assert result["text"] == "Hello world. Second line."
assert result["language"] == "en"
assert result["model"] == "small"
assert result["duration"] == pytest.approx(1.0)
def test_transcribe_maps_bad_payload_to_decode_error(monkeypatch, fake_whisper_server):
_available(monkeypatch)
monkeypatch.setattr(ggml_module, "_cached_model_path", lambda model_id: "/tmp/ggml.bin")
monkeypatch.setattr(_FakeWhisperHandler, "response_text", None)
sidecar = GgmlSttSidecar()
def fake_load(model = None):
sidecar._port = fake_whisper_server
sidecar._model_id = ggml_module.resolve_ggml_model_id(model)
monkeypatch.setattr(sidecar, "load", fake_load)
from core.inference.stt_sidecar import SttAudioDecodeError
with pytest.raises(SttAudioDecodeError):
sidecar.transcribe(b"RIFF", model = "small")
def test_beam_size_matches_fast_flag(monkeypatch, fake_whisper_server):
_available(monkeypatch)
monkeypatch.setattr(ggml_module, "_cached_model_path", lambda model_id: "/tmp/ggml.bin")
seen: list[bytes] = []
orig_post = _FakeWhisperHandler.do_POST
def capture_post(handler):
length = int(handler.headers.get("Content-Length", "0"))
body = handler.rfile.read(length)
seen.append(body)
payload = json.dumps({"text": "ok"}).encode()
handler.send_response(200)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(payload)))
handler.end_headers()
handler.wfile.write(payload)
monkeypatch.setattr(_FakeWhisperHandler, "do_POST", capture_post)
try:
sidecar = GgmlSttSidecar()
def fake_load(model = None):
sidecar._port = fake_whisper_server
sidecar._model_id = ggml_module.resolve_ggml_model_id(model)
monkeypatch.setattr(sidecar, "load", fake_load)
sidecar.transcribe(b"RIFF", model = "small", fast = True)
sidecar.transcribe(b"RIFF", model = "small", fast = False)
finally:
_FakeWhisperHandler.do_POST = orig_post
assert b'name="beam_size"\r\n\r\n1' in seen[0]
assert b'name="beam_size"\r\n\r\n5' in seen[1]
# Dictation defaults to deterministic decoding.
assert b'name="temperature"\r\n\r\n0.0' in seen[0]
def test_download_rejects_custom_ids():
with pytest.raises(SttModelIdError):
ggml_module.start_model_download("owner/model")
def test_download_status_idle_shape():
status = ggml_module.download_status()
assert set(status) >= {"downloading", "model", "error"}

View file

@ -0,0 +1,219 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Regressions for a fresh review pass on the local STT dictation feature:
1. Curated GGUF dictation repos (unslothai/whisper-*-GGUF) must be hidden from
chat pickers, not just their Transformers safetensors companions.
2. The GGUF sidecar's loaded_model/device status accessors must be lock-free so
they never block behind an in-flight transcription (which holds self._lock).
3. A "gguf" unload on a host without whisper-server must target the Transformers
fallback that actually served it, and unload-all must attempt both backends
even if one raises.
4. free_stt_model_for_training must free the GGUF sidecar even when the
Transformers unload raises (independent exception boundaries).
"""
from __future__ import annotations
import asyncio
import sys
import threading
from pathlib import Path
import pytest
from fastapi import HTTPException
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
# 1. Hidden-model GGUF companions ------------------------------------------------
def test_curated_gguf_dictation_repos_are_hidden():
from utils.hidden_models import _HIDDEN_STT_REPO_IDS, is_hidden_model
for repo in (
"unslothai/whisper-tiny-GGUF",
"unslothai/whisper-base-GGUF",
"unslothai/whisper-small-GGUF",
"unslothai/whisper-large-v3-turbo-GGUF",
"unslothai/whisper-large-v3-GGUF",
):
assert repo in _HIDDEN_STT_REPO_IDS
assert is_hidden_model(repo) is True
# Case-insensitive, matching how the cache stores the repo id.
assert is_hidden_model(repo.lower()) is True
# A same-prefix but genuinely different repo is NOT hidden.
assert is_hidden_model("unslothai/whisper-large-v3-GGUF-finetune") is False
# 2. GGUF status accessors are lock-free ----------------------------------------
def test_gguf_status_accessors_do_not_block_on_the_inference_lock():
from core.inference.stt_ggml_sidecar import GgmlSttSidecar
sidecar = GgmlSttSidecar()
class _AliveProc:
pid = 4321
def poll(self):
return None # still running
sidecar._process = _AliveProc()
sidecar._model_id = "small"
holder_has_lock = threading.Event()
release = threading.Event()
def _hold_inference_lock():
# Mimic transcribe() holding self._lock across the whole HTTP call.
with sidecar._lock:
holder_has_lock.set()
release.wait(timeout = 5)
holder = threading.Thread(target = _hold_inference_lock)
holder.start()
assert holder_has_lock.wait(timeout = 5)
result: dict = {}
def _read_status():
result["model"] = sidecar.loaded_model
result["device"] = sidecar.device
reader = threading.Thread(target = _read_status)
reader.start()
reader.join(timeout = 2)
blocked = reader.is_alive()
release.set()
holder.join(timeout = 5)
reader.join(timeout = 5)
assert not blocked, "loaded_model/device blocked on self._lock (should be lock-free)"
assert result == {"model": "small", "device": "whisper.cpp"}
def test_process_alive_snapshots_process_against_concurrent_unload():
# _process_alive() must read self._process exactly once. The lock-free
# readers (loaded_model/device) can run while unload() nulls self._process;
# the old `self._process is not None and self._process.poll() is None` read it
# twice, so a null landing between the two reads called None.poll(). A
# property that yields the live process on the first read and None afterwards
# reproduces that interleaving deterministically.
from core.inference.stt_ggml_sidecar import GgmlSttSidecar
class _AliveProc:
def poll(self):
return None # still running
live = _AliveProc()
reads = {"n": 0}
class _RacingSidecar(GgmlSttSidecar):
@property
def _process(self):
reads["n"] += 1
return live if reads["n"] == 1 else None
@_process.setter
def _process(self, value):
pass # __init__ assigns None; the property drives the read
sidecar = GgmlSttSidecar()
sidecar.__class__ = _RacingSidecar # data descriptor wins over the instance attr
# Snapshot fix: exactly one read, no AttributeError from a second None read.
assert sidecar._process_alive() is True
assert reads["n"] == 1
# 3. Unload resolves through the serving engine + attempts every backend ---------
def test_gguf_unload_targets_transformers_fallback_without_whisper_server(monkeypatch):
import core.inference.stt_ggml_sidecar as ggml_module
import routes.inference as ri
monkeypatch.setattr(ggml_module, "is_available", lambda: False) # no whisper-server
calls: list = []
class _Sidecar:
def __init__(self, name):
self.name = name
def unload(self):
calls.append(self.name)
monkeypatch.setattr(ri, "_stt_sidecar_for", lambda name: _Sidecar(name))
resp = asyncio.run(ri.stt_unload(engine = "gguf", current_subject = "tester"))
assert resp.status_code == 200
# gguf is served by the Transformers fallback here, so that is what unloads.
assert calls == ["transformers"]
def test_unload_all_attempts_both_backends_even_when_one_fails(monkeypatch):
import routes.inference as ri
attempted: list = []
class _Sidecar:
def __init__(self, name):
self.name = name
def unload(self):
attempted.append(self.name)
if self.name == "transformers":
raise RuntimeError("boom")
monkeypatch.setattr(ri, "_stt_sidecar_for", lambda name: _Sidecar(name))
with pytest.raises(HTTPException) as excinfo:
asyncio.run(ri.stt_unload(engine = None, current_subject = "tester"))
assert excinfo.value.status_code == 500
# gguf is still attempted after the transformers unload raised.
assert attempted == ["transformers", "gguf"]
# 4. free_stt_model_for_training isolates the two backends -----------------------
def test_free_stt_frees_gguf_even_when_transformers_unload_raises(monkeypatch):
import routes.training_vram as tv
class _TransformersSidecar:
def is_loading(self):
return False
@property
def loaded_model(self):
return "whisper-small"
def unload(self):
raise RuntimeError("transformers unload failed")
class _GgmlSidecar:
def __init__(self):
self.unloaded = False
def is_loading(self):
return False
@property
def loaded_model(self):
return None if self.unloaded else "small"
def unload(self):
self.unloaded = True
ggml = _GgmlSidecar()
monkeypatch.setattr(
"core.inference.stt_sidecar.get_stt_sidecar", lambda: _TransformersSidecar()
)
monkeypatch.setattr("core.inference.stt_ggml_sidecar.get_ggml_stt_sidecar", lambda: ggml)
freed = tv.free_stt_model_for_training("test")
# The Transformers failure must not skip GGUF eviction.
assert ggml.unloaded is True
assert any("small" in entry for entry in freed)

View file

@ -0,0 +1,350 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Regressions for the second review pass on the local STT dictation feature:
1. scripts/build_whisper_cpp.sh must not rm -rf a whisper.cpp/src tree under a
custom Studio home unless Studio itself created it (ownership marker), the
same policy studio/setup.sh applies before its destructive replacements.
2. _snapshot_is_complete must reject pickle (pytorch_model.bin) checkpoints
outright; only safetensors weights count as a usable snapshot.
3. _snapshot_is_complete must require tokenizer assets (tokenizer.json or
vocab.json + merges.txt); weights + config alone decode to blank text.
4. Custom-repo downloads must pin the revision validated beforehand and
restrict snapshot_download to the model/tokenizer/config/preprocessor file
classes (TOCTOU + unbounded-download hardening).
5. The GGML sidecar's readiness probe must not treat an arbitrary local HTTP
responder as whisper-server (mic audio would be posted to it), and the port
reservation must stay held until just before spawn.
"""
from __future__ import annotations
import http.server
import json
import os
import socket
import stat
import subprocess
import sys
import threading
from pathlib import Path
from types import SimpleNamespace
import pytest
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
import core.inference.stt_ggml_sidecar as ggml_module
import core.inference.stt_sidecar as stt_sidecar_module
from core.inference.stt_ggml_sidecar import GgmlSttSidecar, SttEngineUnavailableError
from core.inference.stt_sidecar import validate_remote_model
_BUILD_SCRIPT = _BACKEND_ROOT.parents[1] / "scripts" / "build_whisper_cpp.sh"
# 1. build_whisper_cpp.sh ownership gate ----------------------------------------
def _stub_tools(tmp_path: Path) -> dict:
"""PATH with git/cmake stubs so the script never reaches a real build."""
bin_dir = tmp_path / "stub-bin"
bin_dir.mkdir(exist_ok = True)
for tool in ("git", "cmake"):
stub = bin_dir / tool
stub.write_text("#!/bin/sh\necho stub-%s-invoked >&2\nexit 1\n" % tool)
stub.chmod(stub.stat().st_mode | stat.S_IEXEC)
env = dict(os.environ)
env["PATH"] = f"{bin_dir}:{env['PATH']}"
return env
def _run_build_script(env: dict) -> subprocess.CompletedProcess:
return subprocess.run(
["sh", str(_BUILD_SCRIPT)],
env = env,
capture_output = True,
text = True,
timeout = 60,
)
def test_build_script_refuses_unowned_dir_in_custom_studio_home(tmp_path):
home = tmp_path / "studio-home"
src = home / "whisper.cpp" / "src"
src.mkdir(parents = True)
user_file = src / "user-data.txt"
user_file.write_text("precious")
env = _stub_tools(tmp_path)
env["UNSLOTH_STUDIO_HOME"] = str(home)
result = _run_build_script(env)
assert result.returncode != 0
assert "not marked as an Unsloth-owned" in result.stderr
# The unowned tree, and the user's file inside it, survived untouched.
assert user_file.read_text() == "precious"
def test_build_script_proceeds_when_marker_present(tmp_path):
home = tmp_path / "studio-home"
install = home / "whisper.cpp"
(install / "src").mkdir(parents = True)
(install / ".unsloth-studio-owned").write_text("")
env = _stub_tools(tmp_path)
env["UNSLOTH_STUDIO_HOME"] = str(home)
result = _run_build_script(env)
# Past the guard: it fails later at the stubbed git clone, not the gate.
assert "not marked as an Unsloth-owned" not in result.stderr
assert "stub-git-invoked" in result.stderr
def test_build_script_marks_fresh_custom_install_dir(tmp_path):
home = tmp_path / "studio-home"
home.mkdir()
env = _stub_tools(tmp_path)
env["UNSLOTH_STUDIO_HOME"] = str(home)
_run_build_script(env)
# A directory the script creates is marked so re-runs stay allowed.
assert (home / "whisper.cpp" / ".unsloth-studio-owned").is_file()
def test_build_script_keeps_legacy_home_behavior(tmp_path):
fake_home = tmp_path / "user-home"
src = fake_home / ".unsloth" / "whisper.cpp" / "src"
src.mkdir(parents = True)
env = _stub_tools(tmp_path)
env.pop("UNSLOTH_STUDIO_HOME", None)
env.pop("STUDIO_HOME", None)
env["HOME"] = str(fake_home)
result = _run_build_script(env)
# The legacy managed dir is always Studio-owned; no gate, straight to git.
assert "not marked as an Unsloth-owned" not in result.stderr
assert "stub-git-invoked" in result.stderr
# 2 + 3. _snapshot_is_complete --------------------------------------------------
def _base_snapshot(tmp_path: Path) -> Path:
snap = tmp_path / "snap"
snap.mkdir()
(snap / "config.json").write_text("{}")
(snap / "preprocessor_config.json").write_text("{}")
(snap / "tokenizer.json").write_text("{}")
return snap
def test_pickle_checkpoint_snapshot_is_never_complete(tmp_path):
# A cached pytorch_model.bin is a pickle RCE load path; the snapshot must
# read as incomplete no matter how many shards are present, so update
# re-resolves and _select_snapshot_files fails it closed.
snap = _base_snapshot(tmp_path)
index = {
"weight_map": {
"a": "pytorch_model-00001-of-00002.bin",
"b": "pytorch_model-00002-of-00002.bin",
}
}
(snap / "pytorch_model.bin.index.json").write_text(json.dumps(index))
(snap / "pytorch_model-00001-of-00002.bin").write_bytes(b"w" * 8)
(snap / "pytorch_model-00002-of-00002.bin").write_bytes(b"w" * 8)
assert stt_sidecar_module._snapshot_is_complete(snap) is False
# A single-file pickle checkpoint is likewise rejected; the safetensors
# equivalent in the same dir makes it complete.
(snap / "pytorch_model.bin").write_bytes(b"w" * 8)
assert stt_sidecar_module._snapshot_is_complete(snap) is False
(snap / "model.safetensors").write_bytes(b"w" * 8)
assert stt_sidecar_module._snapshot_is_complete(snap) is True
def test_safe_index_naming_pickle_shards_is_not_complete(tmp_path):
# A safetensors index that references .bin shards would still pickle-load
# via Transformers' per-shard dispatch; the cached snapshot must read as
# incomplete so it re-resolves and fails closed at selection.
snap = _base_snapshot(tmp_path)
(snap / "model.safetensors.index.json").write_text(
json.dumps({"weight_map": {"a": "pytorch_model-00001-of-00001.bin"}})
)
(snap / "pytorch_model-00001-of-00001.bin").write_bytes(b"w" * 8)
assert stt_sidecar_module._snapshot_is_complete(snap) is False
def test_snapshot_without_tokenizer_assets_is_incomplete(tmp_path):
snap = _base_snapshot(tmp_path)
(snap / "model.safetensors").write_bytes(b"w" * 8)
assert stt_sidecar_module._snapshot_is_complete(snap) is True
# Weights + config but no tokenizer decodes to blank text; not complete.
(snap / "tokenizer.json").unlink()
assert stt_sidecar_module._snapshot_is_complete(snap) is False
# The slow vocab.json + merges.txt pair is an accepted alternative.
(snap / "vocab.json").write_text("{}")
assert stt_sidecar_module._snapshot_is_complete(snap) is False
(snap / "merges.txt").write_text("")
assert stt_sidecar_module._snapshot_is_complete(snap) is True
# 4. Revision pinning and allow_patterns ----------------------------------------
def test_validate_remote_model_returns_the_validated_revision(monkeypatch):
revision = "a" * 40
class _FakeApi:
def __init__(self, token = None):
pass
def model_info(
self,
repo,
expand = None,
timeout = None,
):
return SimpleNamespace(config = {"model_type": "whisper"}, sha = revision)
import huggingface_hub
monkeypatch.setattr(huggingface_hub, "HfApi", _FakeApi)
result = validate_remote_model("someone/custom-whisper")
assert result["revision"] == revision
def test_download_pins_revision_and_limits_patterns(monkeypatch):
captured = {}
validated_revision = "a" * 40
head_revision = "b" * 40
def fake_snapshot_download(**kwargs):
captured.update(kwargs)
return "/cached"
class _FakeApi:
def __init__(self, token = None):
pass
def model_info(
self,
repo,
revision = None,
files_metadata = None,
timeout = None,
):
names = (
"config.json",
"preprocessor_config.json",
"tokenizer.json",
"model.safetensors",
)
siblings = [
SimpleNamespace(rfilename = name, size = 10, blob_id = name, lfs = None) for name in names
]
return SimpleNamespace(siblings = siblings, sha = head_revision)
import huggingface_hub
monkeypatch.setattr(huggingface_hub, "HfApi", _FakeApi)
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
state = stt_sidecar_module._SnapshotDownloadState()
# The revision resolved at validation time wins over the current head.
state._run("someone/custom-whisper", None, revision = validated_revision)
assert captured["revision"] == validated_revision
patterns = captured["allow_patterns"]
assert "model.safetensors" in patterns and "tokenizer.json" in patterns
# No wildcard that would admit arbitrary repo contents.
assert "*" not in patterns
# Without a validated revision (curated repos), pin to the metadata head.
captured.clear()
state._run("someone/custom-whisper", None)
assert captured["revision"] == head_revision
assert captured["allow_patterns"]
# 5. GGML readiness must identify whisper-server --------------------------------
class _CannedHandler(http.server.BaseHTTPRequestHandler):
body = b""
def do_GET(self): # noqa: N802
payload = type(self).body
self.send_response(200)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, *args):
pass
def _serve(body: bytes):
handler = type("Handler", (_CannedHandler,), {"body": body})
server = http.server.HTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target = server.serve_forever, daemon = True)
thread.start()
return server, server.server_address[1]
def _fake_alive_process():
return SimpleNamespace(poll = lambda: None, pid = 999999)
def test_wait_for_server_rejects_a_foreign_http_responder(monkeypatch):
server, port = _serve(b"<html>hello from some other local app</html>")
try:
monkeypatch.setattr(ggml_module, "_SERVER_START_TIMEOUT_SECONDS", 1.0)
with pytest.raises(SttEngineUnavailableError, match = "did not start in time"):
GgmlSttSidecar._wait_for_server(_fake_alive_process(), port)
finally:
server.shutdown()
def test_wait_for_server_accepts_the_whisper_server_page(monkeypatch):
server, port = _serve(b"<html><title>Whisper.cpp Server</title></html>")
try:
monkeypatch.setattr(ggml_module, "_SERVER_START_TIMEOUT_SECONDS", 5.0)
GgmlSttSidecar._wait_for_server(_fake_alive_process(), port)
finally:
server.shutdown()
def test_probe_requires_the_managed_child_to_be_alive():
server, port = _serve(b"whisper")
try:
dead = SimpleNamespace(poll = lambda: 0, pid = 999999)
assert GgmlSttSidecar._probe_is_whisper_server(dead, port) is False
assert GgmlSttSidecar._probe_is_whisper_server(_fake_alive_process(), port) is True
finally:
server.shutdown()
def test_port_reservation_is_held_until_released():
reservation, port = GgmlSttSidecar._reserve_free_port()
try:
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
with pytest.raises(OSError):
probe.bind(("127.0.0.1", port))
finally:
probe.close()
finally:
reservation.close()
# Released right before spawn: the port becomes bindable for the child.
child = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
child.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
child.bind(("127.0.0.1", port))
finally:
child.close()

File diff suppressed because it is too large Load diff

View file

@ -97,6 +97,7 @@ def test_lora_identifier_resolves_remote_adapter_base(tmp_path: Path):
repo,
fn,
token = None,
cache_dir = None,
):
assert repo == "someone/my-remote-lora"
assert fn == "adapter_config.json"
@ -128,6 +129,7 @@ def test_lora_identifier_retries_transient_then_resolves(tmp_path: Path):
repo,
fn,
token = None,
cache_dir = None,
):
calls["n"] += 1
if calls["n"] == 1:

View file

@ -566,3 +566,34 @@ def test_db_run_created_before_pump_consumes_events(monkeypatch):
# The pump observed an already-created run; it would be False if the pump
# were started before the eager create.
assert seen["db_created"] is True
def test_startup_flag_reports_training_active_before_proc():
# Between freeing VRAM and _proc going live, a concurrent STT load must see
# training as active so it does not grab the just-freed GPU.
b = TrainingBackend()
b._spawn_in_progress = True
assert b.is_training_active() is True
def test_before_spawn_runs_inside_active_window(monkeypatch):
# The VRAM-freeing hook must run while training already counts as active, or
# an STT load racing it would place Whisper back on the freed GPU.
b = TrainingBackend()
_stub_spawn(monkeypatch)
monkeypatch.setattr(b, "_ensure_db_run_created", lambda: None)
monkeypatch.setattr(b, "_pump_loop", lambda: setattr(b, "_pump_running", False))
active_during_free = {}
def before_spawn():
active_during_free["value"] = b.is_training_active()
assert b.start_training("job_active_window", model_name = "m", before_spawn = before_spawn) is True
if b._pump_thread is not None:
b._pump_thread.join(timeout = 2.0)
assert active_during_free["value"] is True
# The transient flag clears, but the live proc keeps training active.
assert b._spawn_in_progress is False
assert b.is_training_active() is True

View file

@ -82,6 +82,63 @@ def _patch_backends(inf, llama):
return patch.dict(sys.modules, {"core.inference": core_inf, "routes.inference": routes_inf})
def _fake_stt_sidecar(
*,
model = None,
device = None,
loading = False,
):
sidecar = SimpleNamespace(
loaded_model = model,
device = device,
is_loading = lambda: loading,
)
sidecar.cancel_pending_load = MagicMock(return_value = loading)
sidecar.wait_for_load_to_settle = MagicMock()
sidecar.unload = MagicMock()
return sidecar
def _fake_ggml_sidecar(
*,
model = None,
device = None,
loading = False,
):
ggml = SimpleNamespace(
loaded_model = model,
device = device,
is_loading = lambda: loading,
)
ggml.cancel_pending_load = MagicMock(return_value = loading)
ggml.wait_for_load_to_settle = MagicMock()
ggml.unload = MagicMock()
return ggml
def _patch_stt(sidecar):
stt_module = types.ModuleType("core.inference.stt_sidecar")
stt_module.get_stt_sidecar = lambda: sidecar
# A fresh import of the GGUF sidecar pulls names from the fake module
# above and fails; fake it too so test ordering cannot break that import.
ggml_module = types.ModuleType("core.inference.stt_ggml_sidecar")
empty_ggml = _fake_ggml_sidecar()
ggml_module.get_ggml_stt_sidecar = lambda: empty_ggml
return patch.dict(
sys.modules,
{
"core.inference.stt_sidecar": stt_module,
"core.inference.stt_ggml_sidecar": ggml_module,
},
)
def _patch_ggml_stt(sidecar):
ggml_module = types.ModuleType("core.inference.stt_ggml_sidecar")
ggml_module.get_ggml_stt_sidecar = lambda: sidecar
return patch.dict(sys.modules, {"core.inference.stt_ggml_sidecar": ggml_module})
# ── summarize_resident_chat ──────────────────────────────────────────────────
@ -169,6 +226,49 @@ class TestSummarizeResidentChat(_GpuCacheResetMixin, unittest.TestCase):
self.assertTrue(out["any"]) # GGUF still detected
class TestSummarizeResidentStt(_GpuCacheResetMixin, unittest.TestCase):
def test_reports_resident_model(self):
sidecar = _fake_stt_sidecar(model = "small", device = "cuda")
with _patch_stt(sidecar):
out = tv.summarize_resident_stt()
self.assertEqual(out["model"], "small")
self.assertEqual(out["device"], "cuda")
self.assertTrue(out["any"])
self.assertFalse(out["loading"])
def test_reports_inflight_load(self):
sidecar = _fake_stt_sidecar(loading = True)
with _patch_stt(sidecar):
out = tv.summarize_resident_stt()
self.assertTrue(out["any"])
self.assertTrue(out["loading"])
def test_reports_empty_sidecar(self):
with _patch_stt(_fake_stt_sidecar()):
out = tv.summarize_resident_stt()
self.assertFalse(out["any"])
def test_reports_resident_gguf_when_transformers_idle(self):
ggml = _fake_ggml_sidecar(model = "small", device = "whisper.cpp")
with _patch_stt(_fake_stt_sidecar()), _patch_ggml_stt(ggml):
out = tv.summarize_resident_stt()
self.assertEqual(out["model"], "small")
self.assertEqual(out["device"], "whisper.cpp")
self.assertTrue(out["any"])
def test_resident_transformers_does_not_mask_loading_gguf(self):
# A Transformers model resident on CPU holds no VRAM, but a GGUF
# whisper-server still binding its accelerator backend does; the CPU
# model must not hide that in-flight startup from training admission.
sidecar = _fake_stt_sidecar(model = "small", device = "cpu")
ggml = _fake_ggml_sidecar(loading = True)
with _patch_stt(sidecar), _patch_ggml_stt(ggml):
out = tv.summarize_resident_stt()
self.assertEqual(out["model"], "small")
self.assertTrue(out["loading"])
self.assertTrue(out["any"])
# ── can_keep_during_training (auto mode) ─────────────────────────────────────
@ -438,5 +538,151 @@ class TestFreeChatModels(_GpuCacheResetMixin, unittest.TestCase):
self.assertEqual(freed, ["gguf:gemma.gguf"])
class TestFreeSttModel(_GpuCacheResetMixin, unittest.TestCase):
def test_unloads_resident_model(self):
sidecar = _fake_stt_sidecar(model = "small", device = "cuda")
with _patch_stt(sidecar):
freed = tv.free_stt_model_for_training(reason = "test")
sidecar.unload.assert_called_once()
self.assertEqual(freed, ["stt:small"])
def test_cancels_inflight_load_and_waits_to_settle(self):
sidecar = _fake_stt_sidecar(loading = True)
with _patch_stt(sidecar):
freed = tv.free_stt_model_for_training(reason = "test")
sidecar.cancel_pending_load.assert_called_once()
# The cancelled loader may still hold VRAM; we wait for it to release.
sidecar.wait_for_load_to_settle.assert_called_once()
# No model surfaced after the wait, so nothing to unload.
sidecar.unload.assert_not_called()
self.assertEqual(freed, ["stt:loading"])
def test_cancels_inflight_load_then_unloads_settled_model(self):
# A load that finished before observing the cancel leaves a resident
# model behind; it must be unloaded so training reclaims the memory.
sidecar = _fake_stt_sidecar(model = "small", loading = True)
with _patch_stt(sidecar):
freed = tv.free_stt_model_for_training(reason = "test")
sidecar.cancel_pending_load.assert_called_once()
sidecar.wait_for_load_to_settle.assert_called_once()
sidecar.unload.assert_called_once()
self.assertEqual(freed, ["stt:loading"])
def test_cancelled_load_still_unloads_gguf_sidecar(self):
# Cancelling a Transformers load must not skip the GGUF sidecar; both
# engines can hold memory at once (engine switch or direct load calls).
sidecar = _fake_stt_sidecar(loading = True)
ggml = _fake_ggml_sidecar(model = "small")
with _patch_stt(sidecar), _patch_ggml_stt(ggml):
freed = tv.free_stt_model_for_training(reason = "test")
sidecar.cancel_pending_load.assert_called_once()
ggml.unload.assert_called_once()
self.assertEqual(freed, ["stt:loading", "stt:small"])
def test_leaves_empty_sidecar_alone(self):
sidecar = _fake_stt_sidecar()
with _patch_stt(sidecar):
freed = tv.free_stt_model_for_training(reason = "test")
sidecar.unload.assert_not_called()
self.assertEqual(freed, [])
def test_cancels_inflight_gguf_load_and_waits_to_settle(self):
# A GGUF whisper-server still in startup has no loaded_model yet, so the
# coordinator must cancel and wait for it, not skip it, before training
# claims the accelerator memory it is binding.
sidecar = _fake_stt_sidecar() # Transformers idle
ggml = _fake_ggml_sidecar(loading = True)
with _patch_stt(sidecar), _patch_ggml_stt(ggml):
freed = tv.free_stt_model_for_training(reason = "test")
ggml.cancel_pending_load.assert_called_once()
ggml.wait_for_load_to_settle.assert_called_once()
ggml.unload.assert_not_called() # nothing surfaced after the wait
self.assertEqual(freed, ["stt:gguf-loading"])
class TestCoordinateModels(_GpuCacheResetMixin, unittest.TestCase):
def _run(self, chat, stt, keep_results):
keep = MagicMock(side_effect = keep_results)
with (
patch.object(tv, "summarize_resident_chat", return_value = chat),
patch.object(tv, "summarize_resident_stt", return_value = stt),
patch.object(
tv,
"free_stt_model_for_training",
return_value = ["stt:small"],
) as free_stt,
patch.object(
tv,
"free_chat_models_for_training",
return_value = ["hf:chat"],
) as free_chat,
):
freed = tv.coordinate_models_for_training(keep)
return freed, keep, free_stt, free_chat
def test_keeps_everything_when_training_fits(self):
chat = {"any": True, "loading": False}
stt = {"any": True, "loading": False}
freed, keep, free_stt, free_chat = self._run(
chat,
stt,
[(True, {"usable_gb": 40, "required_gb": 10})],
)
self.assertEqual(freed, [])
keep.assert_called_once()
free_stt.assert_not_called()
free_chat.assert_not_called()
def test_frees_stt_before_chat(self):
chat = {"any": True, "loading": False}
stt = {"any": True, "loading": False}
freed, keep, free_stt, free_chat = self._run(
chat,
stt,
[
(False, {"usable_gb": 8, "required_gb": 10}),
(True, {"usable_gb": 12, "required_gb": 10}),
],
)
self.assertEqual(freed, ["stt:small"])
self.assertEqual(keep.call_count, 2)
free_stt.assert_called_once()
free_chat.assert_not_called()
def test_frees_chat_when_stt_is_not_enough(self):
chat = {"any": True, "loading": False}
stt = {"any": True, "loading": False}
freed, keep, free_stt, free_chat = self._run(
chat,
stt,
[
(False, {"usable_gb": 8, "required_gb": 10}),
(False, {"usable_gb": 9, "required_gb": 10}),
],
)
self.assertEqual(freed, ["stt:small", "hf:chat"])
self.assertEqual(keep.call_count, 2)
free_stt.assert_called_once()
free_chat.assert_called_once()
def test_frees_loading_models_without_probe(self):
chat = {"any": True, "loading": True}
stt = {"any": True, "loading": True}
freed, keep, free_stt, free_chat = self._run(chat, stt, [])
self.assertEqual(freed, ["stt:small", "hf:chat"])
keep.assert_not_called()
free_stt.assert_called_once()
free_chat.assert_called_once()
def test_cancels_loading_stt_without_probe(self):
chat = {"any": False, "loading": False}
stt = {"any": True, "loading": True}
freed, keep, free_stt, free_chat = self._run(chat, stt, [])
self.assertEqual(freed, ["stt:small"])
keep.assert_not_called()
free_stt.assert_called_once()
free_chat.assert_not_called()
if __name__ == "__main__":
unittest.main()

View file

@ -160,6 +160,25 @@ class TestResolveBaseModel:
class TestRemoteLoraBase:
"""_remote_lora_base reads a remote adapter's base from its Hub adapter_config.json."""
@pytest.fixture(autouse = True)
def _selected_cache_follows_env(self, monkeypatch):
# The cache helpers now read the selected cache (get_hf_cache_paths),
# which snapshots env at import; make it follow the HF_HUB_CACHE these
# tests set so they keep driving the lookup via env.
monkeypatch.setattr(
"utils.transformers_version.get_hf_cache_paths",
lambda: _types.SimpleNamespace(
hub_cache = Path(
os.environ.get("HF_HUB_CACHE")
or os.environ.get("HUGGINGFACE_HUB_CACHE")
or os.path.join(
os.environ.get("HF_HOME") or os.path.expanduser("~/.cache/huggingface"),
"hub",
)
)
),
)
@staticmethod
def _resp(cfg: dict):
class _Resp:
@ -645,6 +664,24 @@ def _hf_response(cfg: dict):
class TestConfigJsonHfCacheFallback:
"""HF hub cache is consulted only offline or after a failed fetch (never stale online)."""
@pytest.fixture(autouse = True)
def _selected_cache_follows_env(self, monkeypatch):
# As above: route the selected-cache lookup through the HF_HUB_CACHE env
# these tests set, since get_hf_cache_paths snapshots env at import.
monkeypatch.setattr(
"utils.transformers_version.get_hf_cache_paths",
lambda: _types.SimpleNamespace(
hub_cache = Path(
os.environ.get("HF_HUB_CACHE")
or os.environ.get("HUGGINGFACE_HUB_CACHE")
or os.path.join(
os.environ.get("HF_HOME") or os.path.expanduser("~/.cache/huggingface"),
"hub",
)
)
),
)
def setup_method(self):
_config_json_cache.clear()

View file

@ -0,0 +1,156 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for the whisper.cpp prebuilt freshness check.
Pins the whisper-specific version policy: the release-tag parser, the
is_behind decision matrix (with its downgrade guard), and one end-to-end
wiring smoke through the shared freshness flow. The shared marker-walk and
fail-open mechanics are covered by test_llama_cpp_freshness.py.
"""
from __future__ import annotations
import json
import sys
import types as _types
from datetime import datetime, timedelta, timezone
from pathlib import Path
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
class _NoopLogger:
"""structlog-style logger: every method swallows positional + kwargs."""
def __getattr__(self, _name):
return lambda *a, **k: None
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda *a, **k: _NoopLogger()
sys.modules.setdefault("loggers", _loggers_stub)
_structlog_stub = _types.ModuleType("structlog")
_structlog_stub.get_logger = lambda *a, **k: _NoopLogger()
sys.modules.setdefault("structlog", _structlog_stub)
import pytest
from utils import whisper_cpp_freshness as fr
# Helpers.
def _write_marker(install_dir: Path, **overrides) -> Path:
payload = {
"requested_tag": "latest",
"release_tag": "v1.9.1-unsloth.1",
"upstream_tag": "v1.9.1",
"published_repo": "unslothai/whisper.cpp",
"asset": "whisper-v1.9.1-unsloth.1-linux-x64-cpu.tar.gz",
"asset_sha256": None,
"source": "published",
"installed_at_utc": (datetime.now(tz = timezone.utc) - timedelta(days = 1))
.isoformat()
.replace("+00:00", "Z"),
}
payload.update(overrides)
install_dir.mkdir(parents = True, exist_ok = True)
marker = install_dir / "UNSLOTH_WHISPER_PREBUILT_INFO.json"
marker.write_text(json.dumps(payload))
return marker
def _fake_binary(install_dir: Path) -> Path:
"""Stub whisper-server under the canonical cmake install layout."""
bin_dir = install_dir / "build" / "bin"
bin_dir.mkdir(parents = True, exist_ok = True)
bin_path = bin_dir / "whisper-server"
bin_path.write_text("stub\n")
return bin_path
@pytest.fixture(autouse = True)
def _reset(monkeypatch, tmp_path):
# Isolate disk cache per-test; never touch the real cache.
monkeypatch.setattr(fr, "_cache_dir", lambda: tmp_path / ".freshness")
fr.reset_caches()
yield
fr.reset_caches()
# parse_release_version.
def test_parse_release_version():
assert fr.parse_release_version("v1.9.1-unsloth.2") == (1, 9, 1, 2)
assert fr.parse_release_version("1.10.0") == (1, 10, 0, 0) # no v, no serial
assert fr.parse_release_version(" v2.0.0-unsloth.10 ") == (2, 0, 0, 10)
assert fr.parse_release_version("v1.9") == (1, 9, 0, 0) # padded
assert fr.parse_release_version("nightly") is None
assert fr.parse_release_version(None) is None
assert fr.parse_release_version("") is None
# is_behind decision matrix + downgrade guard.
def test_is_behind_serial_bump():
assert fr.is_behind("v1.9.1-unsloth.1", "v1.9.1-unsloth.2") is True
def test_is_behind_downgrade_guard():
# A lower serial or version is never "behind".
assert fr.is_behind("v1.9.1-unsloth.2", "v1.9.1-unsloth.1") is False
assert fr.is_behind("v1.10.0-unsloth.1", "v1.9.1-unsloth.9") is False
def test_is_behind_upstream_bump():
assert fr.is_behind("v1.9.1-unsloth.1", "v1.10.0-unsloth.1") is True
def test_is_behind_identical_is_false():
assert fr.is_behind("v1.9.1-unsloth.1", "v1.9.1-unsloth.1") is False
def test_is_behind_unparseable_differs_is_behind():
assert fr.is_behind("v1.9.1-unsloth.1", "nightly") is True
def test_is_behind_missing_side_fails_open():
assert fr.is_behind(None, "v1.9.1-unsloth.2") is False
assert fr.is_behind("v1.9.1-unsloth.1", None) is False
# check_prebuilt_freshness end-to-end.
def test_check_prebuilt_freshness_reports_stale_when_old_and_behind(monkeypatch, tmp_path):
_write_marker(
tmp_path,
release_tag = "v1.9.1-unsloth.1",
installed_at_utc = (datetime.now(tz = timezone.utc) - timedelta(days = 10))
.isoformat()
.replace("+00:00", "Z"),
)
bin_path = _fake_binary(tmp_path)
monkeypatch.setattr(fr, "latest_published_release", lambda *a, **k: "v1.9.1-unsloth.3")
info = fr.check_prebuilt_freshness(str(bin_path))
assert info["has_marker"] is True
assert info["behind"] is True
assert info["stale"] is True
assert info["installed_tag"] == "v1.9.1-unsloth.1"
assert info["latest_tag"] == "v1.9.1-unsloth.3"
def test_marker_reader_prefers_install_root_over_packaging_marker(tmp_path):
root_marker = _write_marker(tmp_path, release_tag = "v1.9.1-unsloth.2")
binary = _fake_binary(tmp_path)
(binary.parent / root_marker.name).write_text(
json.dumps({"backend": "slim", "release_tag": "archive-metadata"})
)
assert fr.read_install_marker(str(binary))["release_tag"] == "v1.9.1-unsloth.2"

View file

@ -57,6 +57,18 @@ def test_windows_drive_roots_empty_off_windows(monkeypatch):
assert external_media.windows_drive_roots() == []
def test_macos_volume_roots_lists_readable_mounts(monkeypatch, tmp_path):
volumes = tmp_path / "Volumes"
external = volumes / "External SSD"
unreadable = volumes / "Unavailable"
external.mkdir(parents = True)
unreadable.mkdir()
monkeypatch.setattr(external_media.platform, "system", lambda: "Darwin")
monkeypatch.setattr(external_media.os, "access", lambda path, _mode: Path(path) == external)
assert external_media.macos_volume_roots(volumes) == [external]
def test_windows_drive_roots_lists_readable_drives(monkeypatch):
_stub_windows(monkeypatch, {"C", "D", "E"})
@ -204,8 +216,10 @@ def test_browse_allowlist_includes_windows_drive_roots(monkeypatch, tmp_path):
)
fake_external_media = SimpleNamespace(
linux_run_media_mount_roots = lambda: [],
macos_volume_roots = lambda: [],
windows_drive_roots = lambda: [drive_root],
)
fake_paths.external_media = fake_external_media
fake_studio_db = SimpleNamespace(
list_scan_folders = lambda: [],
contains_sensitive_path_component = lambda _p: False,
@ -270,8 +284,10 @@ def test_build_browse_allowlist_reuses_passed_roots(monkeypatch, tmp_path):
)
fake_external_media = SimpleNamespace(
linux_run_media_mount_roots = _media_roots,
macos_volume_roots = lambda: [],
windows_drive_roots = _drive_roots,
)
fake_paths.external_media = fake_external_media
fake_studio_db = SimpleNamespace(list_scan_folders = lambda: [])
monkeypatch.setitem(sys.modules, "utils.paths", fake_paths)
monkeypatch.setitem(sys.modules, "utils.paths.external_media", fake_external_media)

View file

@ -406,10 +406,13 @@ def convert_to_vlm_format(
elif _image_lookup is not None and image_data in _image_lookup:
# Bare filename → resolve via HF repo lookup
from huggingface_hub import hf_hub_download
from utils.hf_cache_settings import active_hf_hub_cache
local_path = hf_hub_download(
dataset_name,
_image_lookup[image_data],
repo_type = "dataset",
cache_dir = active_hf_hub_cache(),
)
image_data = Image.open(local_path).convert("RGB")
else:
@ -774,10 +777,13 @@ def convert_sharegpt_with_images_to_vlm_format(
return Image.open(BytesIO(f.read())).convert("RGB")
elif _image_lookup is not None and image_data in _image_lookup:
from huggingface_hub import hf_hub_download
from utils.hf_cache_settings import active_hf_hub_cache
local_path = hf_hub_download(
dataset_name,
_image_lookup[image_data],
repo_type = "dataset",
cache_dir = active_hf_hub_cache(),
)
return Image.open(local_path).convert("RGB")
else:

View file

@ -58,6 +58,7 @@ def precache_helper_gguf():
try:
from huggingface_hub import HfApi, hf_hub_download
from huggingface_hub.utils import disable_progress_bars, enable_progress_bars
from utils.hf_cache_settings import active_hf_hub_cache
disable_progress_bars()
logging.getLogger("huggingface_hub").setLevel(logging.WARNING)
@ -76,7 +77,11 @@ def precache_helper_gguf():
+ (f" (+{len(matching) - 1} shards)" if len(matching) > 1 else "")
)
for target in matching:
hf_hub_download(repo_id = repo, filename = target)
hf_hub_download(
repo_id = repo,
filename = target,
cache_dir = active_hf_hub_cache(),
)
logger.info(f"Helper GGUF cached: {len(matching)} file(s)")
else:
logger.warning(f"No GGUF matching variant '{variant}' in {repo}")

View file

@ -0,0 +1,362 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Live, persisted Hugging Face cache routing for Unsloth Studio.
Hugging Face reads cache environment variables at import time. Studio therefore
owns an explicit cache snapshot for each operation instead of trying to refresh
``huggingface_hub.constants`` in the long-running API process.
"""
from __future__ import annotations
import os
import shutil
import tempfile
import threading
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator, Literal, Mapping, Optional
CACHE_HOME_SETTING_KEY = "hugging_face_cache_home"
CACHE_HISTORY_SETTING_KEY = "hugging_face_cache_history"
MAX_CACHE_HISTORY = 16
CacheSource = Literal["default", "studio", "environment"]
_CACHE_ENV_KEYS = (
"HF_HOME",
"HF_HUB_CACHE",
"HUGGINGFACE_HUB_CACHE",
"HF_XET_CACHE",
)
# Imported by storage_roots._setup_cache_env before Studio seeds defaults.
_EXPLICIT_CACHE_ENV = {
key: value.strip()
for key in _CACHE_ENV_KEYS
if (value := os.environ.get(key)) is not None and value.strip()
}
_settings_lock = threading.RLock()
_spawn_env_lock = threading.RLock()
@dataclass(frozen = True)
class HuggingFaceCachePaths:
cache_home: Path
hub_cache: Path
xet_cache: Path
source: CacheSource
environment_variable: Optional[str] = None
@property
def editable(self) -> bool:
return self.source != "environment"
@property
def is_custom(self) -> bool:
return self.source == "studio"
def child_env(self, base: Optional[Mapping[str, str]] = None) -> dict[str, str]:
env = dict(os.environ if base is None else base)
# Do not rewrite HF_HOME. It also owns HF's token path, and credentials
# must not be moved onto a removable cache volume.
env["HF_HUB_CACHE"] = str(self.hub_cache)
env["HF_XET_CACHE"] = str(self.xet_cache)
env.pop("HUGGINGFACE_HUB_CACHE", None)
return env
def _default_cache_home() -> Path:
xdg = (os.environ.get("XDG_CACHE_HOME") or "").strip()
return (Path(xdg).expanduser() if xdg else Path.home() / ".cache") / "huggingface"
def _canonical(path: Path | str) -> Path:
return Path(path).expanduser().resolve(strict = False)
def _environment_paths() -> Optional[HuggingFaceCachePaths]:
explicit_home = _EXPLICIT_CACHE_ENV.get("HF_HOME")
explicit_hub = _EXPLICIT_CACHE_ENV.get("HF_HUB_CACHE") or _EXPLICIT_CACHE_ENV.get(
"HUGGINGFACE_HUB_CACHE"
)
if not explicit_home and not explicit_hub:
return None
explicit_xet = _EXPLICIT_CACHE_ENV.get("HF_XET_CACHE")
default_home = _default_cache_home()
hf_home = _canonical(explicit_home) if explicit_home else default_home
hub = _canonical(explicit_hub) if explicit_hub else hf_home / "hub"
xet = _canonical(explicit_xet) if explicit_xet else hf_home / "xet"
controlling = next(
key
for key in ("HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE", "HF_HOME")
if key in _EXPLICIT_CACHE_ENV
)
# Settings describes model downloads, so an explicit hub path is the
# displayed/opened location even when HF_HOME points somewhere else for
# credentials or XET data.
display_home = (
(hub.parent if explicit_hub and hub.name.lower() == "hub" else hub)
if explicit_hub
else hf_home
)
return HuggingFaceCachePaths(display_home, hub, xet, "environment", controlling)
def _stored_cache_home() -> Optional[Path]:
try:
from storage.studio_db import get_app_setting
value = get_app_setting(CACHE_HOME_SETTING_KEY, None)
except Exception:
return None
if not isinstance(value, str) or not value.strip():
return None
try:
return _canonical(value.strip())
except (OSError, RuntimeError, ValueError):
return None
def get_hf_cache_paths() -> HuggingFaceCachePaths:
env_paths = _environment_paths()
if env_paths is not None:
return env_paths
stored = _stored_cache_home()
if stored is not None:
xet = _EXPLICIT_CACHE_ENV.get("HF_XET_CACHE")
return HuggingFaceCachePaths(
stored,
stored / "hub",
_canonical(xet) if xet else stored / "xet",
"studio",
)
home = _default_cache_home()
xet = _EXPLICIT_CACHE_ENV.get("HF_XET_CACHE")
return HuggingFaceCachePaths(
home,
home / "hub",
_canonical(xet) if xet else home / "xet",
"default",
)
def active_hf_hub_cache() -> str:
"""Return the current hub cache as a string for library call kwargs."""
return str(get_hf_cache_paths().hub_cache)
@contextmanager
def child_environment_for_spawn(environment: Mapping[str, str]) -> Iterator[None]:
"""Apply captured env before spawn imports the child entrypoint.
Applying variables only inside the multiprocessing target can be too late
for libraries that snapshot environment variables at import. The lock keeps
this short parent-process override atomic through ``Process.start()``.
"""
with _spawn_env_lock:
missing = object()
saved_environment: dict[str, str | object] = {}
for key, value in environment.items():
saved_environment[key] = os.environ.get(key, missing)
os.environ[key] = value
try:
yield
finally:
for key, previous in saved_environment.items():
if previous is missing:
os.environ.pop(key, None)
else:
os.environ[key] = str(previous)
def initialize_hf_cache_environment() -> HuggingFaceCachePaths:
"""Seed import-time HF variables once during backend startup."""
paths = get_hf_cache_paths()
# Preserve an explicit HF_HOME, otherwise keep credentials at the platform
# default while routing cache bytes through the selected home.
if not os.environ.get("HF_HOME", "").strip():
os.environ["HF_HOME"] = str(_default_cache_home())
os.environ["HF_HUB_CACHE"] = str(paths.hub_cache)
os.environ["HF_XET_CACHE"] = str(paths.xet_cache)
if "HUGGINGFACE_HUB_CACHE" not in _EXPLICIT_CACHE_ENV:
os.environ.pop("HUGGINGFACE_HUB_CACHE", None)
for directory in (paths.hub_cache, paths.xet_cache):
try:
directory.mkdir(parents = True, exist_ok = True)
except OSError:
pass
return paths
def _validate_cache_home(raw_path: str) -> Path:
value = raw_path.strip()
if not value:
raise ValueError("Choose a cache folder.")
candidate = Path(value).expanduser()
if not candidate.is_absolute():
raise ValueError("The Hugging Face cache folder must be an absolute path.")
try:
resolved = candidate.resolve(strict = False)
except (OSError, RuntimeError, ValueError) as exc:
raise ValueError("The Hugging Face cache folder is invalid.") from exc
if resolved.parent == resolved:
raise ValueError("Choose a folder inside the filesystem or drive root.")
try:
from hub.storage.scan_folders import (
contains_sensitive_path_component,
is_denied_system_path,
)
except ImportError:
contains_sensitive_path_component = is_denied_system_path = None
if is_denied_system_path is not None and is_denied_system_path(str(resolved)):
raise ValueError("System folders cannot be used for model downloads.")
if contains_sensitive_path_component is not None and contains_sensitive_path_component(
str(resolved)
):
raise ValueError("Credential or config folders cannot be used for model downloads.")
parent = resolved.parent
if not parent.exists() or not parent.is_dir():
raise ValueError("The parent folder does not exist.")
try:
resolved.mkdir(exist_ok = True)
if not resolved.is_dir():
raise ValueError("The selected cache location is not a folder.")
for child in (resolved / "hub", resolved / "xet"):
child.mkdir(exist_ok = True)
with tempfile.NamedTemporaryFile(prefix = ".unsloth-write-test-", dir = child):
pass
except PermissionError as exc:
raise ValueError("Studio does not have permission to write to this folder.") from exc
except OSError as exc:
raise ValueError(f"Studio cannot use this cache folder: {exc}") from exc
return resolved
def _stored_history() -> list[Path]:
try:
from storage.studio_db import get_app_setting
raw = get_app_setting(CACHE_HISTORY_SETTING_KEY, [])
except Exception:
raw = []
if not isinstance(raw, list):
return []
out: list[Path] = []
seen: set[str] = set()
for value in raw:
if not isinstance(value, str) or not value.strip():
continue
try:
path = _canonical(value)
except (OSError, RuntimeError, ValueError):
continue
key = os.path.normcase(str(path))
if key in seen:
continue
seen.add(key)
out.append(path)
return out[:MAX_CACHE_HISTORY]
def set_hf_cache_home(cache_home: Optional[str]) -> HuggingFaceCachePaths:
if _environment_paths() is not None:
raise RuntimeError("The Hugging Face cache location is managed by an environment variable.")
with _settings_lock:
previous = _stored_cache_home()
next_home = _validate_cache_home(cache_home) if cache_home is not None else None
history = _stored_history()
if previous is not None and previous != next_home:
history.insert(0, previous)
deduped: list[str] = []
seen: set[str] = set()
for path in history:
key = os.path.normcase(str(path))
if key in seen or path == next_home:
continue
seen.add(key)
deduped.append(str(path))
if len(deduped) >= MAX_CACHE_HISTORY:
break
from storage.studio_db import upsert_app_settings
upsert_app_settings(
{
CACHE_HOME_SETTING_KEY: str(next_home) if next_home is not None else None,
CACHE_HISTORY_SETTING_KEY: deduped,
}
)
# Inventory scans are cached independently from settings. Invalidate after
# persistence so the next request sees both the new active root and history.
from hub.utils.inventory_scan import invalidate_hf_cache_scans
invalidate_hf_cache_scans()
return get_hf_cache_paths()
def known_hf_cache_homes() -> list[Path]:
paths = get_hf_cache_paths()
stored = _stored_cache_home()
candidates: list[Path] = []
if paths.source != "environment":
candidates.append(paths.cache_home)
elif explicit_home := _EXPLICIT_CACHE_ENV.get("HF_HOME"):
candidates.append(_canonical(explicit_home))
if stored is not None:
candidates.append(stored)
candidates.extend([*_stored_history(), _default_cache_home()])
out: list[Path] = []
seen: set[str] = set()
for candidate in candidates:
try:
canonical = _canonical(candidate)
except (OSError, RuntimeError, ValueError):
continue
key = os.path.normcase(str(canonical))
if key in seen:
continue
seen.add(key)
out.append(canonical)
return out
def known_hf_hub_caches() -> list[Path]:
active = get_hf_cache_paths()
out = [active.hub_cache]
seen = {os.path.normcase(str(_canonical(active.hub_cache)))}
for home in known_hf_cache_homes():
hub = _canonical(home / "hub")
key = os.path.normcase(str(hub))
if key not in seen:
seen.add(key)
out.append(hub)
return out
def cache_status(paths: Optional[HuggingFaceCachePaths] = None) -> dict:
paths = paths or get_hf_cache_paths()
available = paths.cache_home.is_dir()
writable = available and os.access(paths.cache_home, os.W_OK | os.X_OK)
free_bytes: Optional[int] = None
if available:
try:
free_bytes = int(shutil.disk_usage(paths.cache_home).free)
except OSError:
pass
return {
"cache_home": str(paths.cache_home),
"hub_cache": str(paths.hub_cache),
"xet_cache": str(paths.xet_cache),
"source": paths.source,
"editable": paths.editable,
"is_custom": paths.is_custom,
"available": available,
"writable": writable,
"free_bytes": free_bytes,
"environment_variable": paths.environment_variable,
}

View file

@ -21,6 +21,8 @@ never triggers the heavy load.
from __future__ import annotations
import threading
from functools import partial
from pathlib import Path
from typing import Any, Callable, Optional
# Defaults mirror unsloth_zoo.hf_xet_fallback; plain literals so they resolve (including as
@ -262,13 +264,23 @@ __all__ = [
]
def _studio_prepare_for_http(repo_type: str, repo_id: str) -> None:
def _studio_prepare_for_http(
repo_type: str,
repo_id: str,
*,
cache_dir: Optional[str] = None,
) -> None:
"""Unsloth's marker-aware purge before an HTTP resume, keeping the download manager's ``.transport``
accounting consistent (vs unsloth_zoo's generic default). Guarded: a purge failure is logged,
not fatal to the retry."""
try:
from hub.utils.download_registry import prepare_cache_for_transport
prepare_cache_for_transport(repo_type, repo_id, "http")
prepare_cache_for_transport(
repo_type,
repo_id,
"http",
root = Path(cache_dir) if cache_dir else None,
)
except Exception as exc:
try:
from loggers import get_logger
@ -293,9 +305,13 @@ def hf_hub_download_with_xet_fallback(
grace_period: float = DEFAULT_GRACE_PERIOD,
on_status: Optional[Callable[[str], None]] = None,
force_download: bool = False,
cache_dir: Optional[str] = None,
) -> str:
"""Single-file download via the shared fallback with Unsloth's marker-aware HTTP-retry prep.
``force_download`` re-fetches a newer blob over a cached one (Unsloth's model-update path)."""
if cache_dir is None:
from utils.hf_cache_settings import get_hf_cache_paths
cache_dir = str(get_hf_cache_paths().hub_cache)
return _shared_hf_hub_download_with_xet_fallback(
repo_id,
filename,
@ -308,11 +324,18 @@ def hf_hub_download_with_xet_fallback(
grace_period = grace_period,
on_status = on_status,
force_download = force_download,
prepare_for_http_fn = _studio_prepare_for_http,
cache_dir = cache_dir,
prepare_for_http_fn = partial(_studio_prepare_for_http, cache_dir = cache_dir),
)
def snapshot_download_with_xet_fallback(repo_id: str, **kwargs: Any) -> str:
"""Whole-repo download via the shared fallback with Unsloth's marker-aware HTTP-retry prep."""
kwargs.setdefault("prepare_for_http_fn", _studio_prepare_for_http)
if kwargs.get("cache_dir") is None:
from utils.hf_cache_settings import get_hf_cache_paths
kwargs["cache_dir"] = str(get_hf_cache_paths().hub_cache)
kwargs.setdefault(
"prepare_for_http_fn",
partial(_studio_prepare_for_http, cache_dir = kwargs["cache_dir"]),
)
return _shared_snapshot_download_with_xet_fallback(repo_id, **kwargs)

Some files were not shown because too many files have changed in this diff Show more