Merge branch 'unslothai:main' into fix-oom-labels-vulkan-6414
This commit is contained in:
commit
79eace774d
327 changed files with 26176 additions and 4815 deletions
33
.github/workflows/cross-platform-parity-ci.yml
vendored
33
.github/workflows/cross-platform-parity-ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
10
.github/workflows/studio-ui-smoke.yml
vendored
10
.github/workflows/studio-ui-smoke.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
102
install.ps1
102
install.ps1
|
|
@ -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.
|
||||
|
|
|
|||
104
install.sh
104
install.sh
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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
71
scripts/build_whisper_cpp.sh
Executable 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"
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
876
studio/backend/core/inference/stt_ggml_sidecar.py
Normal file
876
studio/backend/core/inference/stt_ggml_sidecar.py
Normal 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
|
||||
1142
studio/backend/core/inference/stt_sidecar.py
Normal file
1142
studio/backend/core/inference/stt_sidecar.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
62
studio/backend/hub/tests/test_download_manifest_scoping.py
Normal file
62
studio/backend/hub/tests/test_download_manifest_scoping.py
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
74
studio/backend/routes/whisper.py
Normal file
74
studio/backend/routes/whisper.py
Normal 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)
|
||||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
75
studio/backend/tests/test_change_password_policy.py
Normal file
75
studio/backend/tests/test_change_password_policy.py
Normal 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
|
||||
735
studio/backend/tests/test_combined_update.py
Normal file
735
studio/backend/tests/test_combined_update.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
290
studio/backend/tests/test_hf_cache_settings.py
Normal file
290
studio/backend/tests/test_hf_cache_settings.py
Normal 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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
231
studio/backend/tests/test_install_whisper_prebuilt_checksums.py
Normal file
231
studio/backend/tests/test_install_whisper_prebuilt_checksums.py
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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])]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
168
studio/backend/tests/test_stt_download_validation.py
Normal file
168
studio/backend/tests/test_stt_download_validation.py
Normal 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 == []
|
||||
780
studio/backend/tests/test_stt_ggml_sidecar.py
Normal file
780
studio/backend/tests/test_stt_ggml_sidecar.py
Normal 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"}
|
||||
219
studio/backend/tests/test_stt_review_fixes.py
Normal file
219
studio/backend/tests/test_stt_review_fixes.py
Normal 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)
|
||||
350
studio/backend/tests/test_stt_review_fixes_2.py
Normal file
350
studio/backend/tests/test_stt_review_fixes_2.py
Normal 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()
|
||||
1302
studio/backend/tests/test_stt_sidecar.py
Normal file
1302
studio/backend/tests/test_stt_sidecar.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
156
studio/backend/tests/test_whisper_cpp_freshness.py
Normal file
156
studio/backend/tests/test_whisper_cpp_freshness.py
Normal 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"
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
362
studio/backend/utils/hf_cache_settings.py
Normal file
362
studio/backend/utils/hf_cache_settings.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue