Merge branch 'unslothai:main' into feature/lemonade-rocm-prebuilts

This commit is contained in:
Leo Borcherding 2026-05-18 11:40:55 -05:00 committed by GitHub
commit 3eecc18bf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
130 changed files with 16172 additions and 1884 deletions

View file

@ -316,6 +316,22 @@ jobs:
run: |
python -m pytest -v --tb=short tests/test_public_api_surface.py
- name: callback signature drift detector (HARD GATE)
# Catches the MLX-style bug from PR #5498: a producer in
# unsloth_zoo (or unsloth) grows a callback arg, but a consumer
# callback def still declares the old arity. The producer's
# try/except swallows the resulting TypeError and the symptom is
# "callback never fires" -- usually diagnosed downstream as a
# confusing assertion several seconds later. This static AST
# check fails fast at PR time. UNSLOTH_ZOO_SRC points at the
# freshly cloned main so the detector sees platform-specific
# submodules (e.g. unsloth_zoo/mlx/) that the released wheel
# may strip.
env:
UNSLOTH_ZOO_SRC: ${{ runner.temp }}/unsloth-zoo
run: |
python -m pytest -v --tb=short tests/test_callback_signature_drift.py
- name: unsloth Bucket-A — CPU tests not in Repo tests (CPU)
# 16 tests across 5 files. They live inside tests/saving/ and
# tests/utils/, both of which Repo tests (CPU) excludes via --ignore

View file

@ -183,16 +183,16 @@ jobs:
# available to llama.cpp from CI; gemma-3-270m turn latency
# has been observed to crowd the 180s default. Triple it.
STUDIO_UI_TURN_TIMEOUT_MS: '540000'
# Retry up to 3 times to absorb the racy Playwright Node 24
# pipeTransport.js 'Unexpected end of JSON input' crash that
# fires intermittently on macos-14 free runners (Chromium
# browser process dies mid-test → driver Node process can't
# parse the truncated JSON-RPC line and exits). The retry
# FULLY resets Studio (kill, reset-password, reboot, wait
# /api/health, re-export bootstrap pw) before re-running the
# script so the change-password flow finds a fresh bootstrap.
# A real test failure (assertion / timeout) does NOT match the
# JSON pattern so it bypasses retry and surfaces immediately.
# Retry up to 3 times to absorb known macos-14 free-runner
# flakes: (1) Playwright Node 24 pipeTransport.js 'Unexpected
# end of JSON input' crash when the Chromium browser process
# dies mid-test, and (2) Chromium net::ERR_NO_BUFFER_SPACE
# when the runner's kernel briefly runs out of socket buffers.
# The retry FULLY resets Studio (kill, reset-password, reboot,
# wait /api/health, re-export bootstrap pw) before re-running
# the script. A real test failure (assertion / timeout) does
# NOT match either pattern so it bypasses retry and surfaces
# immediately.
run: |
mkdir -p logs/playwright
attempt=1
@ -205,9 +205,10 @@ jobs:
if [ "$rc" -eq 0 ]; then
break
fi
if grep -q "Unexpected end of JSON input" logs/playwright_attempt_${attempt}.log \
if { grep -q "Unexpected end of JSON input" logs/playwright_attempt_${attempt}.log \
|| grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_attempt_${attempt}.log; } \
&& [ "$attempt" -lt "$max_attempts" ]; then
echo "::warning::Playwright pipeTransport JSON crash on attempt ${attempt}; resetting Studio and retrying..."
echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..."
kill "${STUDIO_PID}" 2>/dev/null || true
sleep 2
unsloth studio reset-password
@ -280,8 +281,8 @@ jobs:
STUDIO_UI_TURN_TIMEOUT_MS: '540000'
GGUF_REPO: ${{ env.GGUF_REPO }}
GGUF_VARIANT: ${{ env.GGUF_VARIANT }}
# Same pipeTransport JSON-crash retry shape as "Drive the chat
# UI with Playwright" -- see comment there.
# Same flake-retry shape as "Drive the chat UI with Playwright"
# -- catches pipeTransport JSON crash and ERR_NO_BUFFER_SPACE.
run: |
mkdir -p logs/playwright_extra
attempt=1
@ -294,9 +295,10 @@ jobs:
if [ "$rc" -eq 0 ]; then
break
fi
if grep -q "Unexpected end of JSON input" logs/playwright_extra_attempt_${attempt}.log \
if { grep -q "Unexpected end of JSON input" logs/playwright_extra_attempt_${attempt}.log \
|| grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_extra_attempt_${attempt}.log; } \
&& [ "$attempt" -lt "$max_attempts" ]; then
echo "::warning::Playwright pipeTransport JSON crash on attempt ${attempt}; resetting Studio and retrying..."
echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..."
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
sleep 2
unsloth studio reset-password

View file

@ -21,6 +21,7 @@ on:
pull_request:
paths:
- 'install.sh'
- 'uninstall.sh'
- 'studio/setup.sh'
- 'studio/install_python_stack.py'
- 'studio/install_llama_prebuilt.py'
@ -137,6 +138,38 @@ jobs:
kill "$PID" 2>/dev/null || true
echo "post-update Studio /api/health OK"
- name: Uninstall and verify clean
# Round-trip through uninstall.sh on real macOS. As a side effect
# this exercises the macOS-only .app bundle + Launch Services
# removal path (~/Applications/Unsloth Studio.app, lsregister -u)
# which is not testable from a Linux runner. Skips gracefully if
# uninstall.sh has not landed yet (lets this workflow merge
# before #5497).
run: |
set -o pipefail
if [ ! -f uninstall.sh ]; then
echo "uninstall.sh not present in this tree; skipping round-trip"
: > logs/uninstall.log
exit 0
fi
sh uninstall.sh 2>&1 | tee logs/uninstall.log
leak=0
for p in \
"$HOME/.unsloth/studio" \
"$HOME/.local/share/unsloth" \
"$HOME/Applications/Unsloth Studio.app" \
"$HOME/Desktop/Unsloth Studio.app" \
"$HOME/.local/bin/unsloth"; do
if [ -e "$p" ] || [ -L "$p" ]; then
echo "::error::leak: $p"
leak=$((leak + 1))
fi
done
[ "$leak" -eq 0 ] || exit 1
sh uninstall.sh 2>&1 | tail -5
sh uninstall.sh 2>&1 | tail -5
echo "PASS: mac install -> update -> uninstall round-trip clean"
- name: Upload update logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@ -147,4 +180,5 @@ jobs:
logs/update.log
logs/update2.log
logs/studio.log
logs/uninstall.log
retention-days: 7

View file

@ -229,12 +229,55 @@ jobs:
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
sleep 2
# IME + multilingual paste regression (issue #5318 / PR #5327).
# Third Studio on its own port so a hang here cannot poison the
# earlier UI tests. No GGUF -- the bug surface is the composer.
- name: Reset auth + boot Studio for IME / i18n tests (port 18896)
run: |
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18896 \
> logs/studio_ime.log 2>&1 &
echo "STUDIO_IME_PID=$!" >> "$GITHUB_ENV"
- name: Wait for /api/health on 18896
run: |
for i in $(seq 1 180); do
if curl -fs "http://127.0.0.1:18896/api/health" > /tmp/health3.json; then
jq -e '.status == "healthy"' /tmp/health3.json && break
fi
sleep 1
done
jq -e '.status == "healthy"' /tmp/health3.json
- name: Pass bootstrap pw for IME / i18n test
# IME smoke does the change-password against the bootstrap that
# Studio's frontend injects into the page, so it only needs the
# NEW password.
run: |
NEW="CIIme-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
echo "::add-mask::$NEW"
echo "STUDIO_IME_NEW_PW=$NEW" >> "$GITHUB_ENV"
- name: Drive IME + multilingual paste regression with Playwright
env:
BASE_URL: http://127.0.0.1:18896
STUDIO_NEW_PW: ${{ env.STUDIO_IME_NEW_PW }}
PW_ART_DIR: logs/playwright_ime
STUDIO_UI_STRICT: '1'
run: |
mkdir -p logs/playwright_ime
python tests/studio/playwright_chat_ime_i18n.py
- name: Stop third Studio
if: always()
run: |
kill "${STUDIO_IME_PID}" 2>/dev/null || true
sleep 2
- name: Upload Playwright artifacts
# Always upload (not just failure) so a green run's screenshots
# are reviewable in the Actions UI -- catches "passed but the
# UI is silently broken" regressions that would be invisible
# otherwise. Both Studio's logs (chat + extra) and BOTH
# Playwright artifact dirs are bundled.
# Always upload so a green run's screenshots stay reviewable --
# catches "passed but the UI is silently broken" regressions.
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@ -242,7 +285,9 @@ jobs:
path: |
logs/studio.log
logs/studio_extra.log
logs/studio_ime.log
logs/install.log
logs/playwright
logs/playwright_extra
logs/playwright_ime
retention-days: 7

View file

@ -15,6 +15,7 @@ on:
pull_request:
paths:
- 'install.sh'
- 'uninstall.sh'
- 'studio/setup.sh'
- 'studio/install_python_stack.py'
- 'studio/install_llama_prebuilt.py'
@ -139,9 +140,44 @@ jobs:
kill "$PID" 2>/dev/null || true
echo "post-update Studio /api/health OK"
- name: Uninstall and verify clean
# Round-trip the installer through uninstall.sh: confirms the
# uninstaller actually finds and removes everything install.sh +
# update wrote. Safety-guard scenarios (refuse-$HOME etc.) belong
# in a separate fast smoke job; this is the happy-path cleanup
# assertion that catches regressions where install.sh starts
# writing to a new location and uninstall.sh hasn't caught up.
# Skips gracefully if uninstall.sh has not landed yet (lets this
# workflow merge before #5497).
run: |
set -o pipefail
if [ ! -f uninstall.sh ]; then
echo "uninstall.sh not present in this tree; skipping round-trip"
: > logs/uninstall.log
exit 0
fi
sh uninstall.sh 2>&1 | tee logs/uninstall.log
leak=0
for p in \
"$HOME/.unsloth/studio" \
"$HOME/.local/share/unsloth" \
"$HOME/Desktop/Unsloth Studio.desktop" \
"$HOME/.local/bin/unsloth"; do
if [ -e "$p" ] || [ -L "$p" ]; then
echo "::error::leak: $p"
ls -la "$p" 2>&1 | head -3
leak=$((leak + 1))
fi
done
[ "$leak" -eq 0 ] || exit 1
# Idempotent: re-runs exit 0 on an empty $HOME.
sh uninstall.sh 2>&1 | tail -5
sh uninstall.sh 2>&1 | tail -5
echo "PASS: install -> update -> uninstall round-trip clean"
- name: Upload update logs
# Always upload so a green run still leaves the install + two
# update logs reviewable.
# update logs + uninstall log reviewable.
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@ -151,4 +187,5 @@ jobs:
logs/update.log
logs/update2.log
logs/studio.log
logs/uninstall.log
retention-days: 7

View file

@ -258,11 +258,26 @@ jobs:
- name: Load the GGUF (HF repo + variant, served from HF_HOME cache)
run: |
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
--max-time 600 \
-d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}" \
| jq '{status, display_name, is_gguf, context_length}'
# Retry the load step a few times so a transient TCP RST during
# llama-server warm-up (Windows runner image churn,
# windows-latest -> windows-2025-vs2026 rollout) doesn't fail
# the whole job. The Studio backend's _wait_for_health now
# catches httpx.ReadError too; this retry layer covers the
# cases the backend can't recover from on its own.
LOAD_OK=0
for attempt in 1 2 3; do
HTTP=$(curl -s -o /tmp/load.json -w '%{http_code}' \
-X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
--max-time 600 \
-d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}")
if [ "$HTTP" = "200" ]; then LOAD_OK=1; break; fi
echo "::warning::/api/inference/load attempt $attempt returned $HTTP; response:"
cat /tmp/load.json || true
sleep 10
done
[ "$LOAD_OK" = "1" ] || { echo "::error::/api/inference/load failed 3 attempts"; exit 22; }
jq '{status, display_name, is_gguf, context_length}' /tmp/load.json
- name: Multi-turn determinism via OpenAI + Anthropic SDKs
env:
@ -350,6 +365,19 @@ jobs:
shell: cmd
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
- name: Collect llama-server logs
if: always()
shell: bash
# Copy llama-server's own stdout/stderr (teed by Studio under
# ~/.unsloth/studio/logs/llama-server/) into the workspace so
# upload-artifact can pick it up. Crucial for diagnosing a
# subprocess crash where Studio's traceback only shows the
# symptom (httpx ReadError) but not the cause.
run: |
mkdir -p logs/llama-server
cp -v ~/.unsloth/studio/logs/llama-server/*.log logs/llama-server/ 2>/dev/null || \
echo "no llama-server logs to collect"
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@ -358,6 +386,7 @@ jobs:
path: |
logs/studio.log
logs/install.log
logs/llama-server/*.log
retention-days: 7
# ─────────────────────────────────────────────────────────────────────
@ -561,11 +590,21 @@ jobs:
# a normal path.
GGUF_PATH="${GITHUB_WORKSPACE//\\//}/gguf-cache/${GGUF_FILE}"
ls -lh "$GGUF_PATH"
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
--max-time 600 \
-d "{\"model_path\":\"$GGUF_PATH\",\"is_lora\":false,\"max_seq_length\":2048}" \
| jq '{status, display_name}'
# Retry: same rationale as the OpenAI/Anthropic job.
LOAD_OK=0
for attempt in 1 2 3; do
HTTP=$(curl -s -o /tmp/load.json -w '%{http_code}' \
-X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
--max-time 600 \
-d "{\"model_path\":\"$GGUF_PATH\",\"is_lora\":false,\"max_seq_length\":2048}")
if [ "$HTTP" = "200" ]; then LOAD_OK=1; break; fi
echo "::warning::/api/inference/load attempt $attempt returned $HTTP; response:"
cat /tmp/load.json || true
sleep 10
done
[ "$LOAD_OK" = "1" ] || { echo "::error::/api/inference/load failed 3 attempts"; exit 22; }
jq '{status, display_name}' /tmp/load.json
- name: Tool calling, server-side tools, thinking on/off
env:
@ -768,6 +807,19 @@ jobs:
shell: cmd
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
- name: Collect llama-server logs
if: always()
shell: bash
# Copy llama-server's own stdout/stderr (teed by Studio under
# ~/.unsloth/studio/logs/llama-server/) into the workspace so
# upload-artifact can pick it up. Crucial for diagnosing a
# subprocess crash where Studio's traceback only shows the
# symptom (httpx ReadError) but not the cause.
run: |
mkdir -p logs/llama-server
cp -v ~/.unsloth/studio/logs/llama-server/*.log logs/llama-server/ 2>/dev/null || \
echo "no llama-server logs to collect"
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@ -776,6 +828,7 @@ jobs:
path: |
logs/studio.log
logs/install.log
logs/llama-server/*.log
retention-days: 7
# ─────────────────────────────────────────────────────────────────────
@ -970,11 +1023,21 @@ jobs:
-H 'content-type: application/json' \
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
echo "API_KEY=$TOKEN" >> "$GITHUB_ENV"
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
--max-time 900 \
-d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}" \
| jq '{status, display_name, is_vision}'
# Retry: same rationale as the OpenAI/Anthropic and Tool calling jobs.
LOAD_OK=0
for attempt in 1 2 3; do
HTTP=$(curl -s -o /tmp/load.json -w '%{http_code}' \
-X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
--max-time 900 \
-d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}")
if [ "$HTTP" = "200" ]; then LOAD_OK=1; break; fi
echo "::warning::/api/inference/load attempt $attempt returned $HTTP; response:"
cat /tmp/load.json || true
sleep 10
done
[ "$LOAD_OK" = "1" ] || { echo "::error::/api/inference/load failed 3 attempts"; exit 22; }
jq '{status, display_name, is_vision}' /tmp/load.json
- name: JSON schema decoding + image input
env:
@ -1156,6 +1219,19 @@ jobs:
shell: cmd
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
- name: Collect llama-server logs
if: always()
shell: bash
# Copy llama-server's own stdout/stderr (teed by Studio under
# ~/.unsloth/studio/logs/llama-server/) into the workspace so
# upload-artifact can pick it up. Crucial for diagnosing a
# subprocess crash where Studio's traceback only shows the
# symptom (httpx ReadError) but not the cause.
run: |
mkdir -p logs/llama-server
cp -v ~/.unsloth/studio/logs/llama-server/*.log logs/llama-server/ 2>/dev/null || \
echo "no llama-server logs to collect"
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@ -1164,4 +1240,5 @@ jobs:
path: |
logs/studio.log
logs/install.log
logs/llama-server/*.log
retention-days: 7

View file

@ -23,6 +23,7 @@ on:
pull_request:
paths:
- 'install.ps1'
- 'uninstall.ps1'
- 'studio/setup.ps1'
- 'studio/setup.bat'
- 'studio/install_python_stack.py'
@ -266,6 +267,39 @@ jobs:
kill "$PID" 2>/dev/null || true
echo "post-update Studio /api/health OK"
- name: Uninstall and verify clean
# Round-trip through uninstall.ps1 against the default install
# tree at %USERPROFILE%\.unsloth\studio. Catches regressions
# where install.ps1 starts writing under a new key (registry,
# Start Menu, %APPDATA%) and uninstall.ps1 has not been updated
# to match. Skips gracefully if uninstall.ps1 has not landed yet
# (lets this workflow merge before #5513).
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null
if (-not (Test-Path "$PWD\uninstall.ps1")) {
Write-Host "uninstall.ps1 not present in this tree; skipping round-trip"
"" | Set-Content logs/uninstall.log
exit 0
}
pwsh -NoProfile -File "$PWD\uninstall.ps1" *>&1 | Tee-Object -FilePath logs/uninstall.log
$leak = 0
foreach ($p in @(
"$env:USERPROFILE\.unsloth\studio",
"$env:USERPROFILE\.unsloth\studio\unsloth_studio",
"$env:USERPROFILE\.unsloth\studio\bin\unsloth.exe"
)) {
if (Test-Path -LiteralPath $p) {
Write-Host "::error::leak: $p"
$leak++
}
}
if ($leak -gt 0) { exit 1 }
# Idempotency.
pwsh -NoProfile -File "$PWD\uninstall.ps1" *>&1 | Select-Object -Last 5
pwsh -NoProfile -File "$PWD\uninstall.ps1" *>&1 | Select-Object -Last 5
Write-Host "PASS: windows install -> update -> uninstall round-trip clean"
- name: Upload update logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@ -276,4 +310,5 @@ jobs:
logs/update.log
logs/update2.log
logs/studio.log
logs/uninstall.log
retention-days: 7

View file

@ -127,6 +127,7 @@ jobs:
run: |
PYTHONPATH=. python -m pytest \
tests/version_compat/test_peft_pinned_symbols.py \
tests/version_compat/test_unsloth_zoo_save_merged_pinned_symbols.py \
-v --tb=short
st-pinned-symbols:

View file

@ -218,10 +218,12 @@ unsloth studio -p 8888
```
#### Uninstall
You can uninstall Unsloth Studio by deleting its install folder usually located under `$HOME/.unsloth/studio` on Mac/Linux/WSL and `%USERPROFILE%\.unsloth\studio` on Windows. Using the `rm -rf` commands will **delete everything**, including your history, cache:
The recommended way to fully remove Unsloth Studio is the matching uninstall script for your OS. It stops any running servers, removes the install dir, the launcher data dir, the desktop shortcut, and any platform-specific entries (macOS `.app` bundle + Launch Services on Mac; Start Menu, `HKCU\Software\Unsloth` registry key and user `PATH` entries on Windows):
* **MacOS, WSL, Linux:** `rm -rf ~/.unsloth/studio`
* **Windows (PowerShell):** `Remove-Item -Recurse -Force "$HOME\.unsloth\studio"`
* **MacOS, WSL, Linux:** `curl -fsSL https://unsloth.ai/uninstall.sh | sh`
* **Windows (PowerShell):** `irm https://unsloth.ai/uninstall.ps1 | iex`
If you only want to drop the install dir and keep the launcher/shortcut for a later reinstall, you can instead run `rm -rf ~/.unsloth/studio` (Mac/Linux/WSL) or `Remove-Item -Recurse -Force "$HOME\.unsloth\studio"` (Windows). The model cache at `~/.cache/huggingface` is not touched by any of these.
For more info, [see our docs](https://unsloth.ai/docs/new/studio/install#uninstall).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

View file

@ -1285,7 +1285,7 @@ shell.Run cmd, 0, False
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.2" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.4" unsloth-zoo }
if ($baseInstallExit -eq 0) {
$NoTorchReq = Find-NoTorchRuntimeFile
if ($NoTorchReq) {
@ -1293,7 +1293,7 @@ shell.Run cmd, 0, False
}
}
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.2" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.4" unsloth-zoo }
}
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
@ -1331,7 +1331,7 @@ shell.Run cmd, 0, False
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.2" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.4" unsloth-zoo }
if ($baseInstallExit -eq 0) {
$NoTorchReq = Find-NoTorchRuntimeFile
if ($NoTorchReq) {
@ -1339,7 +1339,7 @@ shell.Run cmd, 0, False
}
}
} elseif ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.2" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.4" unsloth-zoo }
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
}
@ -1367,7 +1367,7 @@ shell.Run cmd, 0, False
Write-TauriLog "STEP" "Installing unsloth"
substep "installing unsloth (this may take a few minutes)..."
if ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.2" --torch-backend=auto }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.4" --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)

View file

@ -572,7 +572,7 @@ fi
BASE_PORT=8888
MAX_PORT_OFFSET=20
TIMEOUT_SEC=60
POLL_INTERVAL_SEC=1
POLL_INTERVAL_SEC=0.25
LOG_FILE="$DATA_DIR/studio.log"
# why: in env-override mode multiple installs share an OS user; namespace the
# lock and remember our own healthy port so we never attach to an unrelated
@ -727,9 +727,65 @@ _spawn_terminal() {
_cmd="$1"
_os=$(uname)
if [ "$_os" = "Darwin" ]; then
# Escape backslashes and double-quotes for AppleScript string
_cmd_escaped=$(printf '%s' "$_cmd" | sed 's/\\/\\\\/g; s/"/\\"/g')
osascript -e "tell application \"Terminal\" to do script \"$_cmd_escaped\"" >/dev/null 2>&1 && return 0
# AppleEvents are TCC-denied from unsigned .app bundles; spawn
# Terminal via a .command file + Launch Services instead. Server
# is nohup'd so warm relaunches hit the fast-path; watcher + trap
# in the .command couple Terminal close <-> server shutdown.
# `exec` keeps the recorded PID equal to the studio process so
# signals reach studio directly rather than a wrapper shell.
nohup sh -c "exec $_cmd" >> "$LOG_FILE" 2>&1 &
_server_pid=$!
_pid_file="$DATA_DIR/studio-$_launch_port.pid"
printf '%d\n' "$_server_pid" > "$_pid_file" 2>/dev/null || true
_cmd_file="$DATA_DIR/launch-terminal.command"
_logfile_q=$(printf '%s' "$LOG_FILE" | sed "s/'/'\\\\''/g")
_pidfile_q=$(printf '%s' "$_pid_file" | sed "s/'/'\\\\''/g")
if {
{
printf '#!/bin/bash\n'
printf "SERVER_PID=%s\n" "$_server_pid"
printf "PID_FILE='%s'\n" "$_pidfile_q"
# Wait up to 12s for graceful shutdown before SIGKILL.
printf 'shutdown_studio() {\n'
printf ' kill -TERM "$SERVER_PID" 2>/dev/null\n'
printf ' _i=0\n'
printf ' while kill -0 "$SERVER_PID" 2>/dev/null && [ "$_i" -lt 24 ]; do\n'
printf ' sleep 0.5\n'
printf ' _i=$((_i + 1))\n'
printf ' done\n'
printf ' kill -0 "$SERVER_PID" 2>/dev/null && kill -KILL "$SERVER_PID" 2>/dev/null\n'
printf ' rm -f "$PID_FILE" 2>/dev/null\n'
printf '}\n'
printf "tail -n 100 -F '%s' &\n" "$_logfile_q"
printf 'TAIL_PID=$!\n'
# Server gone -> kill tail so bash exits cleanly.
printf '(\n'
printf ' while kill -0 "$SERVER_PID" 2>/dev/null; do sleep 1; done\n'
printf ' kill "$TAIL_PID" 2>/dev/null\n'
printf ') &\n'
printf 'WATCHER_PID=$!\n'
printf "trap 'shutdown_studio; kill \"\$WATCHER_PID\" \"\$TAIL_PID\" 2>/dev/null; exit' HUP INT TERM\n"
printf "trap 'rm -f \"\$PID_FILE\" 2>/dev/null' EXIT\n"
printf 'wait "$TAIL_PID" 2>/dev/null\n'
} > "$_cmd_file" 2>/dev/null \
&& chmod +x "$_cmd_file" 2>/dev/null \
&& open -a Terminal "$_cmd_file" 2>/dev/null
}; then
# Foreground Terminal (Launch Services spawns us backgrounded).
osascript -e 'tell application "Terminal" to activate' >/dev/null 2>&1 || true
return 0
fi
# .command/open failed: kill orphan, fall through to generic fallback.
kill -TERM "$_server_pid" 2>/dev/null || true
_i=0
while kill -0 "$_server_pid" 2>/dev/null && [ "$_i" -lt 6 ]; do
sleep 0.5
_i=$((_i + 1))
done
kill -0 "$_server_pid" 2>/dev/null && kill -KILL "$_server_pid" 2>/dev/null || true
rm -f "$_pid_file" 2>/dev/null || true
echo "[WARN] Could not open Terminal; falling back to background launch" >&2
else
for _term in gnome-terminal konsole xfce4-terminal mate-terminal lxterminal xterm; do
if command -v "$_term" >/dev/null 2>&1; then
@ -1005,6 +1061,17 @@ DESKTOP_EOF
_css_contents="$_css_app/Contents"
_css_macos_dir="$_css_contents/MacOS"
_css_res_dir="$_css_contents/Resources"
# Recreate bundle if root or any subpath is a symlink (mkdir -p follows them).
if [ -L "$_css_app" ] || [ -L "$_css_contents" ] \
|| [ -L "$_css_macos_dir" ] || [ -L "$_css_res_dir" ]; then
rm -rf "$_css_app" 2>/dev/null || {
echo "[ERROR] $_css_app contains a symlinked bundle path; remove manually and re-run install" >&2
return 1
}
elif [ -e "$_css_app" ] && [ ! -d "$_css_app" ]; then
echo "[ERROR] $_css_app exists but is not a directory; remove manually and re-run install" >&2
return 1
fi
mkdir -p "$_css_macos_dir" "$_css_res_dir"
# Info.plist
@ -1782,7 +1849,7 @@ if [ "$_MIGRATED" = true ]; then
# to prevent transitive torch resolution.
run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.5.2" unsloth-zoo
"unsloth>=2026.5.4" unsloth-zoo
_NO_TORCH_RT="$(_find_no_torch_runtime)"
if [ -n "$_NO_TORCH_RT" ]; then
run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
@ -1790,7 +1857,7 @@ if [ "$_MIGRATED" = true ]; then
else
run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.5.2" unsloth-zoo
"unsloth>=2026.5.4" unsloth-zoo
fi
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
substep "overlaying local repo (editable)..."
@ -1958,7 +2025,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--upgrade-package unsloth --upgrade-package unsloth-zoo \
"unsloth>=2026.5.2" unsloth-zoo
"unsloth>=2026.5.4" unsloth-zoo
_NO_TORCH_RT="$(_find_no_torch_runtime)"
if [ -n "$_NO_TORCH_RT" ]; then
run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
@ -1973,7 +2040,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
fi
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \
--upgrade-package unsloth "unsloth>=2026.5.2" unsloth-zoo
--upgrade-package unsloth "unsloth>=2026.5.4" unsloth-zoo
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..."
@ -2005,7 +2072,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 "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.2" --torch-backend=auto
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.4" --torch-backend=auto
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
substep "overlaying unsloth-zoo from git main..."

View file

@ -69,6 +69,7 @@ triton = [
]
huggingfacenotorch = [
"unsloth_zoo>=2026.5.2",
"wheel>=0.42.0",
"packaging",
"numpy",
@ -89,7 +90,7 @@ huggingfacenotorch = [
]
huggingface = [
"unsloth[huggingfacenotorch]",
"unsloth_zoo>=2026.4.8",
"unsloth_zoo>=2026.5.2",
"torchvision",
"unsloth[triton]",
]
@ -579,7 +580,7 @@ colab-ampere-torch220 = [
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
]
colab-new = [
"unsloth_zoo>=2026.4.8",
"unsloth_zoo>=2026.5.2",
"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",

View file

@ -6,15 +6,16 @@
import utils.hardware.hardware as hw
DEFAULT_MODELS_GGUF = [
"unsloth/Qwen3.6-27B-MTP-GGUF",
"unsloth/Qwen3.6-35B-A3B-MTP-GGUF",
"unsloth/gemma-4-E2B-it-GGUF",
"unsloth/gemma-4-E4B-it-GGUF",
"unsloth/gemma-4-31B-it-GGUF",
"unsloth/gemma-4-26B-A4B-it-GGUF",
"unsloth/Qwen3.6-35B-A3B-GGUF",
"unsloth/Qwen3.5-4B-GGUF",
"unsloth/Qwen3.5-9B-GGUF",
"unsloth/Qwen3.5-35B-A3B-GGUF",
"unsloth/Qwen3.5-0.8B-GGUF",
"unsloth/Qwen3.5-4B-MTP-GGUF",
"unsloth/Qwen3.5-9B-MTP-GGUF",
"unsloth/Qwen3.5-35B-A3B-MTP-GGUF",
"unsloth/Qwen3.5-0.8B-MTP-GGUF",
"unsloth/Llama-3.2-1B-Instruct-GGUF",
"unsloth/Llama-3.2-3B-Instruct-GGUF",
"unsloth/Llama-3.1-8B-Instruct-GGUF",
@ -24,15 +25,16 @@ DEFAULT_MODELS_GGUF = [
]
DEFAULT_MODELS_STANDARD = [
"unsloth/Qwen3.6-27B-MTP-GGUF",
"unsloth/Qwen3.6-35B-A3B-MTP-GGUF",
"unsloth/gemma-4-E2B-it-GGUF",
"unsloth/gemma-4-E4B-it-GGUF",
"unsloth/gemma-4-31B-it-GGUF",
"unsloth/gemma-4-26B-A4B-it-GGUF",
"unsloth/Qwen3.6-35B-A3B-GGUF",
"unsloth/Qwen3.5-4B-GGUF",
"unsloth/Qwen3.5-9B-GGUF",
"unsloth/Qwen3.5-35B-A3B-GGUF",
"unsloth/Qwen3.5-0.8B-GGUF",
"unsloth/Qwen3.5-4B-MTP-GGUF",
"unsloth/Qwen3.5-9B-MTP-GGUF",
"unsloth/Qwen3.5-35B-A3B-MTP-GGUF",
"unsloth/Qwen3.5-0.8B-MTP-GGUF",
"unsloth/gemma-4-E2B-it",
"unsloth/gemma-4-E4B-it",
"unsloth/gemma-4-31B-it",

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -69,7 +69,15 @@ _DENYLIST_GROUPS: tuple[frozenset[str], ...] = (
# Single-model server -- Studio runs one model per llama-server
# process and serves its own UI. Enabling multi-model loading or
# llama-server's built-in web UI changes the surface clients see.
# ``--webui``/``--no-webui`` are the legacy spelling; current
# upstream uses ``--ui``/``--no-ui`` + ``--ui-*`` companions.
# Keep both so the denylist matches old and new llama-server
# binaries (Studio's prebuilt vs system-llama.cpp).
frozenset({"--webui", "--no-webui"}),
frozenset({"--ui", "--no-ui"}),
frozenset({"--ui-config"}),
frozenset({"--ui-config-file"}),
frozenset({"--ui-mcp-proxy", "--no-ui-mcp-proxy"}),
frozenset({"--models-dir"}),
frozenset({"--models-preset"}),
frozenset({"--models-max"}),
@ -118,3 +126,101 @@ def validate_extra_args(args: Optional[Iterable[str]]) -> list[str]:
def is_managed_flag(flag: str) -> bool:
"""True if ``flag`` is a Studio-managed llama-server flag."""
return flag in _DENYLIST
# Pass-through flags that shadow first-class ``LoadRequest`` fields
# (max_seq_length, cache_type_kv, speculative_type,
# chat_template_override). Stripped from inherited extras so they
# can't last-wins-override an Apply that re-sets the same first-class
# field.
_CONTEXT_FLAGS: frozenset[str] = frozenset({"-c", "--ctx-size"})
_CACHE_FLAGS: frozenset[str] = frozenset(
{"-ctk", "--cache-type-k", "-ctv", "--cache-type-v"}
)
_SPEC_FLAGS: frozenset[str] = frozenset(
{
"--spec-default",
"--spec-type",
"--spec-ngram-size-n",
"--spec-ngram-size",
"--draft-min",
"--draft-max",
# MTP path (llama.cpp #22673).
"--spec-draft-n-max",
"--spec-draft-n-min",
"--spec-ngram-mod-n-match",
"--spec-ngram-mod-n-min",
"--spec-ngram-mod-n-max",
}
)
_TEMPLATE_FLAGS: frozenset[str] = frozenset(
{
"--chat-template",
"--chat-template-file",
"--chat-template-kwargs",
"--jinja",
"--no-jinja",
}
)
_SHADOWING_FLAGS: frozenset[str] = (
_CONTEXT_FLAGS | _CACHE_FLAGS | _SPEC_FLAGS | _TEMPLATE_FLAGS
)
# Boolean flags inside _SHADOWING_FLAGS that take no value. The
# value-consuming heuristic in strip_shadowing_flags must skip just the
# flag for these, never the following token.
_BOOLEAN_SHADOWING_FLAGS: frozenset[str] = frozenset(
{"--spec-default", "--jinja", "--no-jinja"}
)
def strip_shadowing_flags(
args: Iterable[str],
*,
strip_context: bool = True,
strip_cache: bool = True,
strip_spec: bool = True,
strip_template: bool = True,
) -> list[str]:
"""Strip flags that shadow first-class Studio settings.
Used when the route inherits a previous load's ``llama_extra_args``
so that an inherited ``-c 4096`` cannot override the current
request's ``max_seq_length`` (and equivalents for cache /
speculative / chat template). Each ``strip_*`` flag controls one
group; the route only strips groups whose corresponding first-class
field was actually supplied by the caller, so an inherited
``--chat-template-file`` survives an Apply that omits both
``llama_extra_args`` and ``chat_template_override``.
"""
shadowing: set[str] = set()
if strip_context:
shadowing |= _CONTEXT_FLAGS
if strip_cache:
shadowing |= _CACHE_FLAGS
if strip_spec:
shadowing |= _SPEC_FLAGS
if strip_template:
shadowing |= _TEMPLATE_FLAGS
tokens = [str(a) for a in (args or [])]
out: list[str] = []
i, n = 0, len(tokens)
while i < n:
tok = tokens[i]
flag = _flag_name(tok)
if flag is None or flag not in shadowing:
out.append(tok)
i += 1
continue
# Drop this token. Boolean shadowing flags never carry a value;
# other shadowing flags consume the next token when it isn't a
# flag and the value isn't already packed as ``--key=value``.
if flag in _BOOLEAN_SHADOWING_FLAGS or "=" in tok:
i += 1
elif i + 1 < n and _flag_name(tokens[i + 1]) is None:
i += 2
else:
i += 1
return out

View file

@ -109,40 +109,120 @@ _BLOCKED_COMMANDS = (
)
_SHELL_SEPARATORS = frozenset(
{";", "&&", "||", "|", "&", "\n", "(", ")", "`", "{", "}"}
)
# Bash keywords that introduce a new command position (then $cmd, do $cmd, etc.).
_SHELL_KEYWORDS_AS_SEP = frozenset({"then", "do", "else", "elif"})
# Wrappers whose next non-flag argument is itself the command Bash will exec.
_COMMAND_PREFIXES = frozenset(
{
"env",
"command",
"builtin",
"exec",
"time",
"nohup",
"nice",
"setsid",
"stdbuf",
"timeout",
"ionice",
"chroot",
"sudo",
"doas",
"su",
"xargs",
}
)
_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
_FIND_EXEC_FLAGS = frozenset({"-exec", "-execdir", "-ok", "-okdir"})
def _find_blocked_commands(command: str) -> set[str]:
"""Detect blocked commands using shlex tokenization and regex scanning.
"""Detect blocked commands at shell command position only.
Catches: full paths (/usr/bin/sudo), quoted strings ("sudo"),
split-quotes (su""do), backslash escapes (\\rm), and command-position
words after ;, |, &&, $().
A token is at command position if it is the first token, or if the
preceding token is a shell separator / brace-group opener / keyword
that starts a new command (`then`, `do`, etc.), or a command-prefix
wrapper like `env` / `time` / `xargs` (the next token is the real
command). Tokens in argument position (`grep -r curl .`,
`echo source the data`, `ls /usr/bin/curl`) are passed through.
Also scans `find ... -exec CMD` and recurses into bash -c / cmd /c.
"""
blocked = set()
blocked: set[str] = set()
# 1. shlex tokenization (handles quotes, escapes, concatenation)
# shlex with punctuation_chars splits `;`, `&&`, `||`, `|`, `(`, `)`, `` ` ``
# off as their own tokens so we can detect command position even when a
# caller writes `echo done; rm -rf x` (no whitespace) or quote-splits the
# command name itself (`r''m` collapses to a single token `rm` at command
# position after the `;` separator).
try:
tokens = (
shlex.split(command)
if sys.platform != "win32"
else shlex.split(command, posix = False)
)
if sys.platform == "win32":
tokens = shlex.split(command, posix = False)
else:
lexer = shlex.shlex(command, posix = True, punctuation_chars = ";&|()`")
lexer.whitespace_split = True
tokens = list(lexer)
except ValueError:
tokens = command.split()
for token in tokens:
base = os.path.basename(token).lower()
# Strip common Windows executable extensions so that
# runas.exe, shutdown.bat, etc. match the blocklist.
def _token_basename(tok: str) -> str:
# shlex may glue trailing meta-chars onto a token (`rm;`); strip them
# so the basename match still hits `rm`. Leading shell-state chars
# likewise.
tok = tok.strip(";&|()`{}")
base = os.path.basename(tok).lower()
stem, ext = os.path.splitext(base)
if ext in {".exe", ".com", ".bat", ".cmd"}:
base = stem
return base
expect_command = True # start of string is a command position
prefix_pending = False # last command-position token was env/time/timeout/xargs/...
for token in tokens:
if token in _SHELL_SEPARATORS or token in _SHELL_KEYWORDS_AS_SEP:
expect_command = True
prefix_pending = False
continue
if token.startswith("-"):
# Flags belong to the active command. While a wrapper prefix is
# waiting for its command (`stdbuf -oL cmd`, `xargs -- cmd`),
# keep expect_command intact.
if not prefix_pending:
expect_command = False
continue
if not expect_command:
continue
# FOO=bar prefix: assignment list, next non-assignment token is the command.
if _ASSIGNMENT_RE.match(token):
continue
# `timeout 1 cmd` / `nice -n 5 cmd` style numeric wrapper arg.
if prefix_pending and token.lstrip("-").isdigit():
continue
base = _token_basename(token)
if base in _BLOCKED_COMMANDS:
blocked.add(base)
# Wrappers (`env` / `time` / `xargs` / `sudo`) consume one command; the
# next non-flag, non-numeric token is the real command. `sudo` is
# already in _BLOCKED_COMMANDS, so it's flagged AND we keep walking.
if base in _COMMAND_PREFIXES:
prefix_pending = True
continue
expect_command = False
prefix_pending = False
# 2. Regex: catch blocked words at shell command boundaries
# (semicolons, pipes, &&, ||, backticks, $(), <(), subshells, newlines)
# Uses a single combined pattern for all blocked words.
# Handles optional Unix path prefix (/usr/bin/) and Windows drive
# letter prefix (C:\Windows\...\).
# `find ... -exec CMD ... ;` and `-execdir CMD ... ;` invoke CMD directly.
for i, tok in enumerate(tokens):
if tok in _FIND_EXEC_FLAGS and i + 1 < len(tokens):
base = _token_basename(tokens[i + 1])
if base in _BLOCKED_COMMANDS:
blocked.add(base)
# Regex: blocked words at shell command boundaries that shlex won't see,
# e.g. inside an unquoted $(rm -rf), <(rm), backtick chain, or appended to
# a separator with no whitespace ("foo;rm"). Anchored to command-position
# delimiters; does not match in argument position.
lowered = command.lower()
if _BLOCKED_COMMANDS:
words_alt = "|".join(re.escape(w) for w in sorted(_BLOCKED_COMMANDS))
@ -153,7 +233,7 @@ def _find_blocked_commands(command: str) -> set[str]:
)
blocked.update(re.findall(pattern, lowered))
# 3. Check for nested shell invocations (bash -c 'sudo whoami',
# Nested shell invocations (bash -c 'sudo whoami',
# bash -lc '...', bash --login -c '...', cmd /c '...').
# When a -c or /c flag is found, look backwards for a shell name
# (skipping intermediate flags like --login, -l, -x) and recursively
@ -194,10 +274,13 @@ def _find_blocked_commands(command: str) -> set[str]:
def _build_safe_env(workdir: str) -> dict[str, str]:
"""Build a minimal, credential-free environment for sandboxed subprocesses.
Strips HF_TOKEN, WANDB_API_KEY, AWS_*, GH_TOKEN, LD_PRELOAD, DYLD_*, etc.
Preserves the active Python interpreter and virtualenv directories in PATH
so that pip, uv, and packages installed in the Studio runtime remain
accessible.
Whitelist-built from scratch -- the parent process env is NOT inherited.
Only PATH / HOME / TMPDIR / LANG / TERM / PYTHONIOENCODING (+ VIRTUAL_ENV
or Windows SystemRoot when applicable) reach the child. HF_TOKEN,
WANDB_API_KEY, AWS_*, GH_TOKEN, OPENAI_API_KEY, LD_PRELOAD, DYLD_*, and
every other parent var are absent by construction. HOME points at the
sandbox workdir so HF / wandb / aws SDKs cannot read cached credentials
from the operator's real ~/.
"""
# Start with the directory containing the running Python interpreter
# so that subprocess calls to 'python', 'pip', etc. resolve to the
@ -296,7 +379,17 @@ def _sandbox_preexec():
except (ValueError, OSError, AttributeError):
pass
try:
_resource.setrlimit(_resource.RLIMIT_NOFILE, (1024, 1024))
# Default high enough for multi-shard safetensors mmaps + Python's
# own handle count; tunable via env for installs that hit the cap.
# Clamp to the inherited hard limit so setrlimit doesn't ValueError
# on machines where the parent's hard cap is below the requested
# value (would otherwise leave NOFILE at the parent's default).
nofile = int(os.environ.get("UNSLOTH_STUDIO_SANDBOX_NOFILE", "16384"))
_soft_cur, hard_cur = _resource.getrlimit(_resource.RLIMIT_NOFILE)
target = (
nofile if hard_cur == _resource.RLIM_INFINITY else min(nofile, hard_cur)
)
_resource.setrlimit(_resource.RLIMIT_NOFILE, (target, target))
except (ValueError, OSError, AttributeError):
pass
@ -1327,13 +1420,208 @@ def _check_signal_escape_patterns(code: str):
return True
return False
def _method_call_is_hf_upload(node: ast.Call) -> bool:
"""True for HfApi upload method names on any receiver."""
# Bare method-name fallback (`x.upload_file(...)`) is intentionally fuzzy,
# but should only fire when huggingface_hub / hf_api is actually imported
# somewhere in the snippet -- otherwise paramiko.upload_file, boto3
# create_commit, etc. hit a false positive. We pre-scan for the imports.
_HF_IMPORT_MODULES = (
"huggingface_hub",
"hf_api",
"huggingface_hub.hf_api",
)
def _module_has_hf_import(tree: ast.AST) -> bool:
for n in ast.walk(tree):
if isinstance(n, ast.Import):
for alias in n.names:
if alias.name.split(".", 1)[0] in _HF_IMPORT_MODULES:
return True
elif isinstance(n, ast.ImportFrom):
root = (n.module or "").split(".", 1)[0]
if root in _HF_IMPORT_MODULES:
return True
elif isinstance(n, ast.Call) and n.args:
# __import__('huggingface_hub'), importlib.import_module('huggingface_hub'),
# and bare import_module('huggingface_hub') (via `from importlib import ...`).
arg0 = n.args[0]
if not (isinstance(arg0, ast.Constant) and isinstance(arg0.value, str)):
continue
if arg0.value.split(".", 1)[0] not in _HF_IMPORT_MODULES:
continue
func = n.func
if isinstance(func, ast.Name) and func.id in {
"__import__",
"import_module",
}:
return True
if isinstance(func, ast.Attribute) and func.attr == "import_module":
return True
return False
_hf_in_scope = _module_has_hf_import(tree)
def _method_call_hf_upload_name(node: ast.Call) -> str | None:
"""Return the HF upload method name (`upload_file`, ...) or None.
Catches `HfApi().upload_file(...)` (Attribute) and
`from huggingface_hub import upload_file; upload_file(...)` (Name).
The bare-name branch fires only when an HF import is in scope, mirroring
the Attribute branch's gating so paramiko/boto3 do not false-positive.
"""
if not _hf_in_scope:
return None
f = node.func
if isinstance(f, ast.Attribute) and f.attr in _UPLOAD_HF_METHODS:
return f.attr
if isinstance(f, ast.Name) and f.id in _UPLOAD_HF_METHODS:
return f.id
return None
# Kwargs that ship a credential over the wire. Sandbox env strips HF_TOKEN
# / WANDB_API_KEY / AWS_* up front, so any value here is hard-coded or
# lifted from the parent process.
_HF_SENSITIVE_KWARGS = frozenset(
{
"token",
"hf_token",
"api_token",
"api_key",
"auth_token",
"access_token",
"password",
"secret",
}
)
def _is_os_environ(node: ast.AST) -> bool:
return (
isinstance(node.func, ast.Attribute)
and node.func.attr in _UPLOAD_HF_METHODS
isinstance(node, ast.Attribute)
and node.attr == "environ"
and isinstance(node.value, ast.Name)
and node.value.id == "os"
)
def _reads_env_or_secret(node: ast.AST | None) -> bool:
"""True if any node in the subtree resolves to an env / process read.
Walking the subtree (not just the root) means wrapper calls like
`str(os.environ)`, `json.dumps(os.environ)`, or
`'-'.join(os.environ.values())` are caught too.
Covers: `os.environ`, `os.environ[K]`, `os.environ.get(K)`, `os.getenv(K)`,
bare `getenv(K)` (after `from os import getenv`), and
`subprocess.{run,check_output,Popen,getoutput,getstatusoutput}` which
the LLM could use to lift parent env via `printenv` / `env` / `set`.
"""
if node is None:
return False
for sub in ast.walk(node):
if _is_os_environ(sub):
return True
if isinstance(sub, ast.Call):
f = sub.func
if isinstance(f, ast.Attribute):
if (
f.attr in {"getenv", "getenvb"}
and isinstance(f.value, ast.Name)
and f.value.id == "os"
):
return True
if (
f.attr
in {
"check_output",
"run",
"Popen",
"getoutput",
"getstatusoutput",
}
and isinstance(f.value, ast.Name)
and f.value.id in {"subprocess", "commands"}
):
return True
if isinstance(f, ast.Name) and f.id in {"getenv", "getenvb"}:
return True
return False
def _is_safe_relative_path(path: str) -> bool:
"""Relative path with no leading `/`, `~`, drive letter, or `..` segments."""
if not isinstance(path, str) or not path:
return False
if path[0] in ("/", "\\", "~"):
return False
if len(path) >= 2 and path[1] == ":":
return False
return ".." not in path.replace("\\", "/").split("/")
def _path_arg_is_sandbox_local(node: ast.AST | None) -> bool:
"""Whether the path argument resolves to a sandbox-local literal."""
if node is None:
return False
if isinstance(node, ast.Constant) and isinstance(
node.value, (bytes, bytearray)
):
return True # inline bytes, no file access
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return _is_safe_relative_path(node.value)
if isinstance(node, ast.Call):
f = node.func
is_open = (isinstance(f, ast.Name) and f.id == "open") or (
isinstance(f, ast.Attribute) and f.attr == "open"
)
if is_open and node.args:
a0 = node.args[0]
return (
isinstance(a0, ast.Constant)
and isinstance(a0.value, str)
and _is_safe_relative_path(a0.value)
)
return False
def _hf_upload_violation(node: ast.Call, method_name: str) -> str | None:
"""Inspect an HF upload call; return a violation reason or None.
Policy: HF uploads are allowed only when (a) no sensitive kwarg is set,
(b) no positional / keyword value reads `os.environ` or related env
readers, and (c) the path argument is a sandbox-local literal -- a
relative string with no `..`, an `open(<literal>)`, or inline bytes.
Dynamic / variable paths are rejected; the policy cannot prove safety
statically and the cost of a wrong-allow is a credential exfiltration.
"""
for kw in node.keywords or []:
if kw.arg in _HF_SENSITIVE_KWARGS:
return (
f"HF upload {kw.arg}= cannot be set from sandboxed code; "
"uploads run with the sandbox identity only"
)
all_values = list(node.args or []) + [kw.value for kw in (node.keywords or [])]
for v in all_values:
if _reads_env_or_secret(v):
return (
"HF upload cannot include os.environ / os.getenv / subprocess "
"env reads; secrets and tokens must not be exfiltrated"
)
if method_name == "create_commit":
for kw in node.keywords or []:
if kw.arg == "operations" and isinstance(kw.value, ast.List):
for elt in kw.value.elts:
if isinstance(elt, ast.Call):
inner = _hf_upload_violation(elt, "upload_file")
if inner:
return inner
return None
path_node: ast.AST | None = node.args[0] if node.args else None
for kw in node.keywords or []:
if kw.arg in ("path_or_fileobj", "folder_path"):
path_node = kw.value
break
if not _path_arg_is_sandbox_local(path_node):
return (
"HF upload path must be a sandbox-local relative-path literal "
"(no absolute paths, no '..' segments, no dynamic expressions)"
)
return None
class NetworkAndIoVisitor(ast.NodeVisitor):
def visit_Call(self, node):
parts: list[str] = []
@ -1345,14 +1633,17 @@ def _check_signal_escape_patterns(code: str):
parts.insert(0, cur.id)
fq = ".".join(parts) if parts else ""
if _method_call_is_hf_upload(node):
network_calls.append(
{
"type": "upload_blocked",
"line": getattr(node, "lineno", -1),
"description": ("Blocked: file upload disallowed in sandbox"),
}
)
hf_upload_name = _method_call_hf_upload_name(node)
if hf_upload_name is not None:
violation = _hf_upload_violation(node, hf_upload_name)
if violation is not None:
network_calls.append(
{
"type": "upload_blocked",
"line": getattr(node, "lineno", -1),
"description": f"Blocked: {violation}",
}
)
# Direct sock.connect((host, port)) bypasses the FQ-prefix branch below.
if (

View file

@ -648,6 +648,36 @@ def run_inference_process(
os.environ["HF_HUB_DISABLE_XET"] = "1"
logger.info("Xet transport disabled (HF_HUB_DISABLE_XET=1)")
# Offline auto-detect: skip 25s of hf_hub_download retries per file
# if DNS is dead; cached files resolve instantly under HF_HUB_OFFLINE=1.
# Scope is this subprocess only -- orchestrator spawns a fresh worker
# per load (see core/inference/orchestrator.py), so the env cannot
# persist across loads.
if "HF_HUB_OFFLINE" not in os.environ:
import socket as _socket
import threading as _threading
# Probe on a daemon thread so concurrent sockets in the parent
# interpreter are not affected by socket.setdefaulttimeout.
_result: list = [None]
def _probe() -> None:
try:
_socket.gethostbyname("huggingface.co")
_result[0] = False
except Exception:
_result[0] = True
_t = _threading.Thread(target = _probe, daemon = True)
_t.start()
_t.join(2.0)
if _result[0] is None or _result[0] is True:
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
logger.warning(
"huggingface.co unreachable; HF_HUB_OFFLINE=1 set for this worker."
)
import warnings
from loggers.config import LogConfig

View file

@ -59,7 +59,9 @@ from dataclasses import dataclass
import pandas as pd
from datasets import Dataset, load_dataset
from core.inference.llama_cpp import _hf_offline_if_dns_dead
from utils.models import is_vision_model, detect_audio_type
from utils.models.model_config import _env_offline
from utils.datasets import format_and_template_dataset
from utils.datasets import MODEL_TO_TEMPLATE_MAPPER, TEMPLATE_TO_RESPONSES_MAPPER
from utils.datasets.raw_text import prepare_raw_text_dataset
@ -617,7 +619,8 @@ class UnslothTrainer:
# Proactive gated-model check: verify access BEFORE from_pretrained.
# Catches ALL gated/private models (text, vision, audio) globally.
if "/" in model_name: # Only check HF repo IDs, not local paths
# Skip when offline -- from_pretrained will use the cache.
if "/" in model_name and not _env_offline():
try:
from huggingface_hub import model_info as hf_model_info

View file

@ -19,6 +19,7 @@ import math
import multiprocessing as mp
import os
import queue
import re
import shutil
import threading
import time
@ -40,11 +41,18 @@ from utils.paths import outputs_root
logger = get_logger(__name__)
_HF_TMP_CHECKPOINT_RE = re.compile(r"^tmp-checkpoint-\d+$")
def _cleanup_cancelled_checkpoints(output_dir: str | os.PathLike) -> None:
"""Remove ``checkpoint-<int>`` subdirs after a cancelled run.
Only paths whose realpath is under outputs_root are touched."""
"""Remove only HF Trainer ``tmp-checkpoint-<step>/`` partials after a cancel.
Completed ``checkpoint-<int>/`` dirs and any non-numeric-suffix tmp dir
are user-owned and survive. Symlinked output_dir / children are skipped
so containment cannot be bypassed.
"""
out = Path(output_dir)
if not out.exists():
if not out.exists() or not out.is_dir() or out.is_symlink():
return
try:
out_real = out.resolve()
@ -54,7 +62,6 @@ def _cleanup_cancelled_checkpoints(output_dir: str | os.PathLike) -> None:
try:
out_real.relative_to(out_root_real)
except ValueError:
# Refuse to delete anything outside the configured outputs root.
logger.warning(
"Skipping checkpoint cleanup - %s is not under outputs_root %s",
out_real,
@ -62,14 +69,10 @@ def _cleanup_cancelled_checkpoints(output_dir: str | os.PathLike) -> None:
)
return
removed = 0
for entry in out.iterdir() if out.is_dir() else []:
if not entry.is_dir():
for entry in out.iterdir():
if not entry.is_dir() or entry.is_symlink():
continue
name = entry.name
if not name.startswith("checkpoint-"):
continue
tail = name[len("checkpoint-") :]
if not tail.isdigit():
if not _HF_TMP_CHECKPOINT_RE.match(entry.name):
continue
try:
shutil.rmtree(entry, ignore_errors = False)
@ -77,7 +80,7 @@ def _cleanup_cancelled_checkpoints(output_dir: str | os.PathLike) -> None:
except OSError as exc:
logger.warning("Could not remove %s: %s", entry, exc)
logger.info(
"Cancelled-run cleanup removed %d checkpoint dir(s) under %s",
"Cancelled-run cleanup removed %d in-flight tmp-checkpoint dir(s) under %s",
removed,
out,
)
@ -378,8 +381,6 @@ class TrainingBackend:
if self._pump_thread is not None and self._pump_thread.is_alive():
self._pump_thread.join(timeout = 8.0)
# Drop checkpoint-* dirs on explicit cancel only; stop-and-save
# keeps its artifacts.
if cancelled and output_dir:
try:
_cleanup_cancelled_checkpoints(output_dir)

View file

@ -52,6 +52,23 @@ _MAMBA_SSM_RELEASE_TAG = "v2.3.1"
_MAMBA_SSM_PACKAGE_VERSION = "2.3.1"
_FLASH_ATTN_RUNTIME_MIN_SEQ_LEN = 32768
_FLASH_ATTN_SKIP_ENV = "UNSLOTH_STUDIO_SKIP_FLASHATTN_INSTALL"
# apache-tvm-ffi 0.1.10/0.1.11 crash Triton with "CUDA: misaligned address" on sm_100.
_TILELANG_PACKAGE_VERSION = "0.1.8"
_APACHE_TVM_FFI_PACKAGE_VERSION = "0.1.9"
_TILELANG_SKIP_ENV = "UNSLOTH_STUDIO_SKIP_TILELANG_INSTALL"
# Pin both so plain pip cannot silently upgrade torch under the worker (fla-core needs torch>=2.7).
_FLA_PACKAGE_VERSION = "0.5.0"
_FLA_CORE_PACKAGE_VERSION = "0.5.0"
_FLA_SKIP_ENV = "UNSLOTH_STUDIO_SKIP_FLA_INSTALL"
# `--no-deps` saves torch but loses fla-core's transitive deps; `packaging` is also undeclared upstream.
_FLA_RUNTIME_DEPS = ("einops", "packaging", "triton")
_FLA_MIN_TORCH = (2, 7)
_FLA_MIN_PYTHON = (3, 10)
# tilelang 0.1.8 ships wheels only for these Linux arches and macOS arm64; never fall back to its 93MB sdist.
_TILELANG_SUPPORTED_LINUX_MACHINES = frozenset(("x86_64", "amd64", "aarch64", "arm64"))
_TILELANG_INSTALL_TIMEOUT_S = 600
_TVM_FFI_BROKEN_VERSIONS = ("0.1.10", "0.1.11")
_FAST_PATH_HOOKS_SKIP_ENV = "UNSLOTH_STUDIO_SKIP_FAST_PATH_HOOKS"
def _model_wants_causal_conv1d(model_name: str) -> bool:
@ -77,6 +94,38 @@ def _model_wants_causal_conv1d(model_name: str) -> bool:
)
def _hipcc_gcc_install_dir() -> str | None:
"""Return the highest-numbered ``/usr/lib/gcc/x86_64-linux-gnu/<N>`` that has
BOTH the gcc runtime dir AND the corresponding ``/usr/include/c++/<N>`` C++
headers, or ``None`` if no match (or non-Linux / non-x86_64).
Ubuntu 24.04 ships ``/usr/lib/gcc/x86_64-linux-gnu/14/`` (gcc-14 runtime
objects) but does NOT ship ``/usr/include/c++/14`` in its default apt set;
libstdc++ headers come from ``libstdc++-13-dev``. ROCm clang-20 picks the
highest-numbered runtime dir by default, finds no ``<cstdlib>``, and the
HIP source build fails with::
/opt/rocm-X.Y/lib/llvm/lib/clang/20/include/__clang_hip_runtime_wrapper.h:112:10:
fatal error: 'cstdlib' file not found
Returning a path lets the caller pass ``--gcc-install-dir=<path>`` to clang
via ``HIPCC_COMPILE_FLAGS_APPEND``. Mirrors the same loop ``bbf004c`` added
to ``studio/setup.sh`` for the llama.cpp HIP build branch (PR #5301).
"""
if not sys.platform.startswith("linux"):
return None
import platform as _platform
if _platform.machine().lower() != "x86_64":
return None
for _ver in (14, 13, 12, 11):
_runtime = f"/usr/lib/gcc/x86_64-linux-gnu/{_ver}/include"
_headers = f"/usr/include/c++/{_ver}"
if os.path.isdir(_runtime) and os.path.isdir(_headers):
return f"/usr/lib/gcc/x86_64-linux-gnu/{_ver}"
return None
def _install_package_wheel_first(
*,
event_queue: Any,
@ -113,7 +162,7 @@ def _install_package_wheel_first(
if wheel_url is None:
logger.info("No compatible %s wheel candidate", display_name)
elif url_exists(wheel_url):
_send_status(event_queue, f"Installing prebuilt {display_name} wheel...")
_send_status(event_queue, f"Installing {display_name} for faster training...")
for installer, result in install_wheel(
wheel_url,
python_executable = sys.executable,
@ -155,7 +204,9 @@ def _install_package_wheel_first(
"(this may take several minutes)..."
)
else:
pypi_status_message = f"Installing {display_name} from PyPI..."
pypi_status_message = (
f"Installing {display_name} from PyPI for faster training..."
)
_send_status(event_queue, pypi_status_message)
@ -212,6 +263,30 @@ def _install_package_wheel_first(
}
if is_hip:
_run_kwargs["timeout"] = 1800
# On Ubuntu 24.04 + ROCm clang-20, the HIP source build (causal-conv1d,
# mamba-ssm source fallback, flash-attn source fallback) defaults to
# /usr/lib/gcc/x86_64-linux-gnu/14/ which has the runtime dir but no
# /usr/include/c++/14 headers, and dies at:
# __clang_hip_runtime_wrapper.h:112:10:
# fatal error: 'cstdlib' file not found
# Inject --gcc-install-dir for a gcc whose C++ headers actually exist.
# Respect any pre-existing --gcc-install-dir in HIPCC_COMPILE_FLAGS_APPEND
# (user knows best); otherwise append. Mirrors the same fix bbf004c
# added to studio/setup.sh for the llama.cpp HIP build (PR #5301).
_existing_flags = os.environ.get("HIPCC_COMPILE_FLAGS_APPEND", "")
if "--gcc-install-dir" not in _existing_flags:
_gcc_dir = _hipcc_gcc_install_dir()
if _gcc_dir is not None:
_appended = (f"{_existing_flags} --gcc-install-dir={_gcc_dir}").strip()
_env = _run_kwargs.get("env", os.environ).copy()
_env["HIPCC_COMPILE_FLAGS_APPEND"] = _appended
_run_kwargs["env"] = _env
logger.info(
"HIP source build for %s: appended "
"--gcc-install-dir=%s to HIPCC_COMPILE_FLAGS_APPEND",
display_name,
_gcc_dir,
)
try:
result = _sp.run(pypi_cmd, **_run_kwargs)
@ -275,6 +350,168 @@ def _ensure_causal_conv1d_fast_path(event_queue: Any, model_name: str) -> None:
)
def _installed_torch_version_tuple() -> tuple[int, int] | None:
"""Return ``(major, minor)`` of the installed torch, else None."""
try:
from importlib.metadata import version as _pkg_version
raw = _pkg_version("torch").split("+", 1)[0]
parts = raw.split(".")
return (int(parts[0]), int(parts[1]))
except Exception:
return None
def _flash_linear_attention_importable() -> bool:
"""Catch any exception (not just ImportError) so a broken native lib doesn't abort the worker."""
try:
import fla.modules # noqa: F401
import fla.ops.gated_delta_rule # noqa: F401
return True
except Exception as exc:
logger.warning(
"flash-linear-attention is not importable; continuing with install/fallback: %s",
exc,
)
return False
def _flash_linear_attention_current(already_importable: bool | None = None) -> bool:
"""True iff FLA imports AND is at the pinned version (older FLA lacks gated_delta_rule kernels)."""
if already_importable is None:
already_importable = _flash_linear_attention_importable()
if not already_importable:
return False
try:
from importlib.metadata import version as _pkg_version
from packaging.version import Version
fla_v = Version(_pkg_version("flash-linear-attention"))
core_v = Version(_pkg_version("fla-core"))
return fla_v >= Version(_FLA_PACKAGE_VERSION) and core_v >= Version(
_FLA_CORE_PACKAGE_VERSION
)
except Exception as exc:
logger.warning(
"flash-linear-attention importable but version check failed; treating as stale: %s",
exc,
)
return False
def _ensure_flash_linear_attention_unconditional(event_queue: Any) -> bool:
"""Install pinned FLA + fla-core with --no-deps. Returns True iff importable post-call."""
if os.getenv(_FLA_SKIP_ENV) == "1":
return False
if sys.version_info < _FLA_MIN_PYTHON:
logger.info(
"Skipping flash-linear-attention install: requires Python >= %d.%d, have %s",
_FLA_MIN_PYTHON[0],
_FLA_MIN_PYTHON[1],
sys.version.split()[0],
)
return False
torch_ver = _installed_torch_version_tuple()
if torch_ver is not None and torch_ver < _FLA_MIN_TORCH:
_send_status(
event_queue,
(
f"Skipping flash-linear-attention install: fla-core requires "
f"torch>={_FLA_MIN_TORCH[0]}.{_FLA_MIN_TORCH[1]}, have "
f"{torch_ver[0]}.{torch_ver[1]}"
),
)
return False
# Probe once; reuse result so the --force-reinstall decision and the short-circuit
# share the same call count (stable for tests).
already_importable = _flash_linear_attention_importable()
if already_importable and _flash_linear_attention_current(already_importable = True):
logger.info("flash-linear-attention already importable at the pinned version")
return True
_send_status(
event_queue,
f"Installing flash-linear-attention=={_FLA_PACKAGE_VERSION} for faster training...",
)
# `--no-deps` blocks the silent torch upgrade; we bring the non-torch runtime deps in by hand.
specs = [
*_FLA_RUNTIME_DEPS,
f"fla-core=={_FLA_CORE_PACKAGE_VERSION}",
f"flash-linear-attention=={_FLA_PACKAGE_VERSION}",
]
extra_args = ["--no-deps"]
if already_importable:
# Older FLA already imported; pip skips reinstall without this flag.
extra_args.append("--force-reinstall")
if shutil.which("uv"):
pypi_cmd = [
"uv",
"pip",
"install",
"--python",
sys.executable,
*extra_args,
*specs,
]
else:
pypi_cmd = [
sys.executable,
"-m",
"pip",
"install",
*extra_args,
*specs,
]
try:
result = _sp.run(
pypi_cmd,
stdout = _sp.PIPE,
stderr = _sp.STDOUT,
text = True,
timeout = _TILELANG_INSTALL_TIMEOUT_S,
)
except _sp.TimeoutExpired:
logger.warning("flash-linear-attention install timed out; continuing")
_send_status(
event_queue, "flash-linear-attention install timed out; continuing"
)
return False
if result.returncode != 0:
logger.warning(
"flash-linear-attention install failed (continuing on torch fallback):\n%s",
result.stdout,
)
_send_status(
event_queue,
"flash-linear-attention install failed; continuing without it",
)
return False
# pip can exit 0 with a missing transitive runtime dep; verify the import.
if not _flash_linear_attention_importable():
_send_status(
event_queue,
"flash-linear-attention installed but is not importable; continuing without it",
)
return False
logger.info("Installed flash-linear-attention for the FLA fast path")
return True
def _ensure_flash_linear_attention(event_queue: Any, model_name: str) -> None:
"""Legacy model-name-gated FLA install, used when UNSLOTH_STUDIO_SKIP_FAST_PATH_HOOKS=1."""
if not _model_wants_tilelang(model_name):
return
_ensure_flash_linear_attention_unconditional(event_queue)
_SSM_MODEL_SUBSTRINGS = (
"nemotron_h",
"nemotron-h",
@ -303,6 +540,382 @@ def _ensure_mamba_ssm(event_queue: Any, model_name: str) -> None:
)
# Auto-derived from installed transformers: model_types whose modeling_*.py imports `from fla.*`.
# Cached per process. Empty when transformers can't be inspected -> we skip tilelang pre-install
# (the FLA Triton path still runs via the runtime hook).
_TRANSFORMERS_FLA_MODEL_TYPES_CACHE: frozenset[str] | None = None
_MODEL_NAME_SEP_CHARS = ("-", ".", "/", " ")
def _discover_fla_model_types() -> frozenset[str]:
"""Model_types in the installed transformers whose modeling file imports `from fla.*`."""
global _TRANSFORMERS_FLA_MODEL_TYPES_CACHE
if _TRANSFORMERS_FLA_MODEL_TYPES_CACHE is not None:
return _TRANSFORMERS_FLA_MODEL_TYPES_CACHE
found: set[str] = set()
try:
import transformers
models_root = Path(transformers.__file__).parent / "models"
for modeling in models_root.glob("*/modeling_*.py"):
try:
src = modeling.read_text(encoding = "utf-8", errors = "ignore")
except OSError:
continue
if "from fla." in src:
found.add(modeling.parent.name)
except Exception as exc:
logger.debug("FLA model-type discovery skipped: %s", exc)
_TRANSFORMERS_FLA_MODEL_TYPES_CACHE = frozenset(found)
return _TRANSFORMERS_FLA_MODEL_TYPES_CACHE
def _model_wants_tilelang(model_name: str) -> bool:
"""True iff model_name normalizes to contain a discovered FLA model_type."""
types = _discover_fla_model_types()
if not types:
return False
name = model_name.lower()
for sep in _MODEL_NAME_SEP_CHARS:
name = name.replace(sep, "_")
return any(t in name for t in types)
def _installed_tvm_ffi_version() -> str | None:
"""Installed apache-tvm-ffi version, or None if missing/unimportable."""
try:
from importlib.metadata import version as _pkg_version
return _pkg_version("apache-tvm-ffi")
except Exception:
return None
def _tilelang_importable() -> bool:
"""Catch any exception (not just ImportError) so a broken native lib doesn't abort the worker."""
try:
import tilelang # noqa: F401
import tvm_ffi # noqa: F401
return True
except Exception as exc:
logger.warning(
"tilelang/tvm_ffi is not importable; continuing with install/fallback: %s",
exc,
)
return False
def _torch_has_hip() -> bool:
"""True iff torch is a ROCm build; `torch.version.hip` is the only reliable signal on x86_64 ROCm."""
try:
import torch as _torch
return getattr(_torch.version, "hip", None) is not None
except Exception:
return False
def _tilelang_platform_supported() -> bool:
"""True iff a tilelang 0.1.8 wheel will load: Linux x86_64/aarch64, non-HIP torch.
HIP excluded because tilelang 0.1.8 has no HIP GEMM instruction and crashes mid-backward.
"""
import platform as _platform
if not sys.platform.startswith("linux"):
return False
if _platform.machine().lower() not in _TILELANG_SUPPORTED_LINUX_MACHINES:
return False
if _torch_has_hip():
return False
return True
def _pip_install_cmd(*args: str) -> list[str]:
"""`uv pip install` if uv is on PATH, else `python -m pip install`."""
if shutil.which("uv"):
return ["uv", "pip", "install", "--python", sys.executable, *args]
return [sys.executable, "-m", "pip", "install", *args]
def _run_pip(cmd: list[str], event_queue: Any, label: str) -> bool:
"""Run a pip install and surface success/failure via status events."""
try:
result = _sp.run(
cmd,
stdout = _sp.PIPE,
stderr = _sp.STDOUT,
text = True,
timeout = _TILELANG_INSTALL_TIMEOUT_S,
)
except _sp.TimeoutExpired:
logger.warning("%s install timed out; continuing", label)
_send_status(event_queue, f"{label} install timed out; continuing")
return False
if result.returncode != 0:
logger.warning(
"%s install failed (continuing without it):\n%s", label, result.stdout
)
_send_status(event_queue, f"{label} install failed; continuing")
return False
return True
def _ensure_tilelang_backend_unconditional(event_queue: Any) -> bool:
"""Install pinned tilelang + apache-tvm-ffi; two-step repair if a broken tvm-ffi is present.
Returns True iff both import post-call. Step 1 surgically downgrades a broken tvm-ffi
with --force-reinstall --no-deps so torch / CUDA stay untouched; step 2 is a regular
install for missing transitive deps. Bypass via UNSLOTH_STUDIO_SKIP_TILELANG_INSTALL=1.
"""
if os.getenv(_TILELANG_SKIP_ENV) == "1":
return False
if sys.version_info < _FLA_MIN_PYTHON:
logger.info(
"Skipping tilelang install: requires Python >= %d.%d, have %s",
_FLA_MIN_PYTHON[0],
_FLA_MIN_PYTHON[1],
sys.version.split()[0],
)
return False
if not _tilelang_platform_supported():
import platform as _platform
logger.info(
"Skipping tilelang install: no prebuilt wheel for %s/%s",
sys.platform,
_platform.machine(),
)
return False
existing_tvm_ffi = _installed_tvm_ffi_version()
needs_repair = existing_tvm_ffi in _TVM_FFI_BROKEN_VERSIONS
if not needs_repair and _tilelang_importable():
logger.info("tilelang + apache-tvm-ffi already installed")
return True
# Step 1: --no-deps keeps --force-reinstall from touching torch/CUDA via the dep graph.
if needs_repair:
logger.info(
"Forcing apache-tvm-ffi downgrade: %s is on the broken list",
existing_tvm_ffi,
)
_send_status(
event_queue,
(
f"Downgrading apache-tvm-ffi {existing_tvm_ffi} -> "
f"{_APACHE_TVM_FFI_PACKAGE_VERSION} (broken-versions list)"
),
)
repair_cmd = _pip_install_cmd(
"--only-binary=:all:",
"--force-reinstall",
"--no-deps",
f"apache-tvm-ffi=={_APACHE_TVM_FFI_PACKAGE_VERSION}",
)
if not _run_pip(repair_cmd, event_queue, "TileLang backend repair"):
return False
# Step 2: regular install pulls in transitive deps (z3-solver, ml-dtypes) without touching torch.
_send_status(
event_queue,
f"Installing TileLang=={_TILELANG_PACKAGE_VERSION} for faster training...",
)
install_cmd = _pip_install_cmd(
"--only-binary=:all:",
f"apache-tvm-ffi=={_APACHE_TVM_FFI_PACKAGE_VERSION}",
f"tilelang=={_TILELANG_PACKAGE_VERSION}",
)
if not _run_pip(install_cmd, event_queue, "TileLang backend"):
return False
# pip can exit 0 while a native lib (libz3.so) is missing; verify the import.
if not _tilelang_importable():
_send_status(
event_queue,
"TileLang backend installed but is not importable; continuing on the FLA Triton path",
)
return False
logger.info("Installed TileLang backend for FLA fast path")
return True
def _ensure_tilelang_backend(event_queue: Any, model_name: str) -> None:
"""Legacy substring-gated tilelang installer (opt-out path)."""
if not _model_wants_tilelang(model_name):
return
_ensure_tilelang_backend_unconditional(event_queue)
# ── Fast-path hooks ──
# Wrap transformers' is_{flash_linear_attention,causal_conv1d}_available so the first call
# (at modeling import time) drives the install. Any model that queries the gate gets the
# install; models that never query it (Llama, Gemma, dense Qwen) pay nothing.
# UNSLOTH_STUDIO_SKIP_FAST_PATH_HOOKS=1 falls back to the legacy substring path.
def _rebind_in_already_imported_modules(
*, attr_name: str, old_obj: Any, new_obj: Any
) -> int:
"""Rebind `attr_name -> new_obj` in every module that already imported `old_obj`.
`from X import Y` creates a local binding that reassigning X.Y won't reach.
Uses `__dict__.get` (not `getattr`) to skip lazy `__getattr__` aliases.
"""
count = 0
missing = object()
for mod_name, mod in list(sys.modules.items()):
if mod is None:
continue
module_dict = getattr(mod, "__dict__", None)
if not isinstance(module_dict, dict):
continue
existing = module_dict.get(attr_name, missing)
if existing is old_obj:
try:
setattr(mod, attr_name, new_obj)
count += 1
except Exception as exc:
logger.debug("Could not rebind %s in %s: %s", attr_name, mod_name, exc)
return count
def _install_fast_path_hooks(event_queue: Any, model_name: str) -> None:
"""Hook transformers' is_*_available gates so the first call drives the install.
Idempotent. UNSLOTH_STUDIO_SKIP_FAST_PATH_HOOKS=1 falls back to the substring gate.
"""
if os.getenv(_FAST_PATH_HOOKS_SKIP_ENV) == "1":
logger.info("Fast-path hooks disabled via env; using substring fallback")
return
# On HIP torch, even already-installed tilelang crashes FLA's TileLang dispatch.
# User can override with FLA_TILELANG=1.
if _torch_has_hip() and os.environ.get("FLA_TILELANG") is None:
os.environ["FLA_TILELANG"] = "0"
logger.info(
"HIP/ROCm torch detected; setting FLA_TILELANG=0 (no HIP GEMM in tilelang 0.1.8)"
)
try:
from transformers.utils import import_utils as _iu
except Exception as exc:
logger.warning(
"transformers.utils.import_utils not importable; skipping fast-path hooks: %s",
exc,
)
return
def _make_wrapper(
original: Callable[[], bool],
install_fn: Callable[[Any], bool],
gate_name: str,
post_available_fn: Callable[[Any], None] | None = None,
) -> Callable[[], bool]:
state = {"installed": False}
def wrapper() -> bool:
if state["installed"]:
return original()
try:
original.cache_clear() # defensive; worker subprocess is fresh
except AttributeError:
pass
ok = original()
ran_install = False
if not ok:
ran_install = True
logger.info("Hook fired for %s; triggering install", gate_name)
try:
ok = bool(install_fn(event_queue))
except Exception as exc:
logger.warning(
"%s install raised: %s; falling back to torch", gate_name, exc
)
ok = False
logger.info("%s hook done; available=%s", gate_name, ok)
# post_available_fn handles "gate already True but ancillary kernel broken" (e.g. tilelang
# missing while FLA imports fine); skip when install_fn already chained the follow-up.
if ok and not ran_install and post_available_fn is not None:
try:
post_available_fn(event_queue)
except Exception as exc:
logger.warning(
"%s post-available step raised: %s; continuing", gate_name, exc
)
state["installed"] = True
return ok
wrapper.__wrapped__ = original # type: ignore[attr-defined]
wrapper.cache_clear = getattr(original, "cache_clear", lambda: None) # type: ignore[attr-defined]
return wrapper
def _fla_install(eq: Any) -> bool:
# FLA alone ~2.35x; +tilelang adds ~26%. tilelang is GDN-only (Qwen3.5 family).
if not _ensure_flash_linear_attention_unconditional(eq):
logger.info(
"FLA install did not produce an importable runtime; skipping TileLang"
)
return False
if _model_wants_tilelang(model_name):
_ensure_tilelang_backend_unconditional(eq)
else:
logger.info(
"Model %r outside TileLang allowlist; FLA Triton path is sufficient",
model_name,
)
return True
def _fla_post_available(eq: Any) -> None:
# FLA already imports; repair tilelang if missing or on the broken tvm-ffi list.
if not _model_wants_tilelang(model_name):
return
if (
_installed_tvm_ffi_version() not in _TVM_FFI_BROKEN_VERSIONS
and _tilelang_importable()
):
return
_ensure_tilelang_backend_unconditional(eq)
def _causal_conv1d_install(eq: Any) -> bool:
ok = _install_package_wheel_first(
event_queue = eq,
import_name = "causal_conv1d",
display_name = "causal-conv1d",
pypi_name = "causal-conv1d",
pypi_version = _CAUSAL_CONV1D_PACKAGE_VERSION,
filename_prefix = "causal_conv1d",
release_tag = _CAUSAL_CONV1D_RELEASE_TAG,
release_base_url = (
"https://github.com/Dao-AILab/causal-conv1d/releases/download"
),
)
return bool(ok)
for gate_name, install_fn, post_fn in (
("is_flash_linear_attention_available", _fla_install, _fla_post_available),
("is_causal_conv1d_available", _causal_conv1d_install, None),
):
original = getattr(_iu, gate_name, None)
if original is None:
logger.info(
"%s missing on transformers.utils.import_utils; skipping hook",
gate_name,
)
continue
wrapped = _make_wrapper(original, install_fn, gate_name, post_fn)
setattr(_iu, gate_name, wrapped)
rebound = _rebind_in_already_imported_modules(
attr_name = gate_name, old_obj = original, new_obj = wrapped
)
logger.info(
"Installed fast-path hook on %s (rebound %d modules)", gate_name, rebound
)
def _should_try_runtime_flash_attn_install(max_seq_length: int) -> bool:
if os.getenv(_FLASH_ATTN_SKIP_ENV) == "1":
return False
@ -1025,6 +1638,36 @@ def run_training_process(
"ignore" # Suppress warnings at C-level before imports
)
# Offline auto-detect: skip ~25s of HF retries per call when DNS is
# dead. Scoped to this subprocess (orchestrator spawns a fresh one).
if "HF_HUB_OFFLINE" not in os.environ:
import socket as _socket
import threading as _threading
# Daemon thread so we don't mutate process-wide setdefaulttimeout.
_result: list = [None]
def _probe() -> None:
try:
_socket.gethostbyname("huggingface.co")
_result[0] = False
except Exception:
_result[0] = True
_t = _threading.Thread(target = _probe, daemon = True)
_t.start()
_t.join(2.0)
if _result[0] is None or _result[0] is True:
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
os.environ.setdefault("HF_DATASETS_OFFLINE", "1")
# logger isn't configured yet; print to stderr instead.
print(
"huggingface.co unreachable; HF_HUB_OFFLINE=1 set for this worker.",
file = sys.stderr,
flush = True,
)
import warnings
from loggers.config import LogConfig
@ -1113,9 +1756,28 @@ def run_training_process(
model_name,
)
# ── 1b. Set up causal-conv1d first, then install mamba-ssm if needed ──
# ── 1b. Install fast-path kernel libraries for the chosen model.
#
# 1) causal-conv1d ALWAYS runs eagerly via the substring path.
# Some SSM modeling files (nemotron_h, falcon_h1, granitemoehybrid)
# use `lazy_load_kernel("causal-conv1d")` directly and never call
# transformers' `is_causal_conv1d_available()`, so the runtime
# hook on that gate would not fire for them.
# 2) FLA + tilelang: primary gate is the runtime hook on transformers'
# `is_flash_linear_attention_available`. Models whose architecture
# queries that gate auto-trigger the install; others never pay.
# `_install_fast_path_hooks` also wraps `is_causal_conv1d_available`
# as a defence in depth for newer modeling files that do use it.
# 3) mamba-ssm + flash-attn keep their existing substring / size gates.
# 4) `UNSLOTH_STUDIO_SKIP_FAST_PATH_HOOKS=1` falls back to the
# substring path for FLA / tilelang.
try:
_ensure_causal_conv1d_fast_path(event_queue, model_name)
if os.getenv(_FAST_PATH_HOOKS_SKIP_ENV) == "1":
_ensure_flash_linear_attention(event_queue, model_name)
_ensure_tilelang_backend(event_queue, model_name)
else:
_install_fast_path_hooks(event_queue, model_name)
_ensure_mamba_ssm(event_queue, model_name)
_ensure_flash_attn_for_long_context(
event_queue,
@ -1127,7 +1789,9 @@ def run_training_process(
"type": "error",
"error": (
f"Please choose another model to train, since "
f"causal-conv1d / mamba-ssm failed to install "
f"a fast-path kernel library "
f"(causal-conv1d / flash-linear-attention / "
f"mamba-ssm / tilelang) failed to install "
f"with error: {exc}"
),
"stack": traceback.format_exc(limit = 20),

View file

@ -198,6 +198,43 @@ async def lifespan(app: FastAPI):
# Detect hardware first — sets DEVICE global used everywhere
detect_hardware()
# llama.cpp probes: capability (MTP support) + freshness (release age).
# Both cached; freshness has a 24h disk TTL.
try:
from core.inference.llama_cpp import LlamaCppBackend
from utils.llama_cpp_freshness import (
check_prebuilt_freshness,
format_stale_warning,
)
_bin = LlamaCppBackend._find_llama_server_binary()
_caps = LlamaCppBackend.probe_server_capabilities(_bin)
app.state.llama_cpp_capabilities = _caps
_freshness = check_prebuilt_freshness(_bin)
app.state.llama_cpp_freshness = _freshness
import structlog as _structlog
_log = _structlog.get_logger(__name__)
if _caps.get("found") and not _caps.get("supports_mtp"):
_msg = (
"llama.cpp prebuilt lacks MTP support "
"(--spec-type mtp/draft-mtp). Run `unsloth studio update`. "
"MTP GGUFs will load without speculative decoding."
)
_log.warning(_msg)
print(f"WARNING: {_msg}", flush = True)
if _freshness.get("stale"):
_msg = format_stale_warning(_freshness)
_log.warning(_msg)
print(f"WARNING: {_msg}", flush = True)
except Exception as _probe_exc:
import structlog as _structlog
_structlog.get_logger(__name__).debug(
"llama.cpp startup probes failed: %s", _probe_exc
)
from storage.studio_db import cleanup_orphaned_runs
try:
@ -267,7 +304,8 @@ logger = LogConfig.setup_logging(
app.add_middleware(LoggingMiddleware)
# Web-search favicons load from *.gstatic.com; everything else is same-origin.
# Citation favicons load from www.google.com/s2/favicons; *.gstatic.com is
# kept for legacy web-search faviconV2 paths. Everything else is same-origin.
from starlette.middleware.base import BaseHTTPMiddleware # noqa: E402
from starlette.requests import Request as _StarletteRequest # noqa: E402
@ -283,7 +321,7 @@ def _build_csp(script_nonce: "str | None" = None) -> str:
"default-src 'self'; "
"img-src 'self' data: blob: https://t0.gstatic.com "
"https://t1.gstatic.com https://t2.gstatic.com "
"https://t3.gstatic.com; "
"https://t3.gstatic.com https://www.google.com; "
"connect-src 'self' https://huggingface.co https://datasets-server.huggingface.co; "
"style-src 'self' 'unsafe-inline'; "
f"{script_src}; "
@ -494,14 +532,32 @@ app.include_router(
@app.get("/api/health")
async def health_check(request: Request):
"""Liveness only; full diagnostic dict gated on a valid bearer."""
minimal = {
"""Liveness plus launcher capability bits; install fingerprint gated on a valid bearer.
Unauthenticated callers (Tauri watchdog, frontend bootstrap polls) need
``service`` / ``studio_root_id`` / ``chat_only`` / ``desktop_*`` / ``native_path_leases_supported``
to (a) re-adopt a sibling backend across restarts and (b) gate UI surfaces
before any token is available. None of those leak install path or version.
``version`` / ``studio_version`` / ``device_type`` still require a bearer
because they fingerprint the host.
"""
base = {
"status": "healthy",
"timestamp": datetime.now().isoformat(),
"service": "Unsloth UI Backend",
"chat_only": _hw_module.CHAT_ONLY,
"desktop_protocol_version": 1,
"desktop_manageability_version": 1,
"supports_desktop_auth": True,
"supports_desktop_backend_ownership": True,
# Opaque per-install id; launchers reject sibling Studios on the same port.
"studio_root_id": _studio_root_id(),
"native_path_leases_supported": native_path_leases_supported(),
**({"desktop_owner": owner} if (owner := _desktop_owner()) else {}),
}
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
return minimal
return base
try:
from auth.authentication import get_current_subject as _gcs
from fastapi.security import HTTPAuthorizationCredentials
@ -512,29 +568,19 @@ async def health_check(request: Request):
# Must await: a bare coroutine is truthy and would skip the auth check.
subject = await _gcs(creds)
except HTTPException:
return minimal
return base
except Exception:
return minimal
return base
if not subject:
return minimal
return base
platform_map = {"darwin": "mac", "win32": "windows", "linux": "linux"}
device_type = platform_map.get(sys.platform, sys.platform)
return {
**minimal,
"service": "Unsloth UI Backend",
**base,
"version": UNSLOTH_VERSION,
"studio_version": STUDIO_VERSION,
"device_type": device_type,
"chat_only": _hw_module.CHAT_ONLY,
"desktop_protocol_version": 1,
"desktop_manageability_version": 1,
"supports_desktop_auth": True,
"supports_desktop_backend_ownership": True,
# Hex digest of the install path; launchers reject sibling Studios on the same port.
"studio_root_id": _studio_root_id(),
"native_path_leases_supported": native_path_leases_supported(),
**({"desktop_owner": owner} if (owner := _desktop_owner()) else {}),
}

View file

@ -342,6 +342,28 @@ class InferenceStatusResponse(BaseModel):
None,
description = "Active speculative decoding mode (e.g. 'ngram-simple', 'ngram-mod'), or None if disabled",
)
llama_cpp_supports_mtp: bool = Field(
True,
description = (
"Whether llama.cpp supports MTP (--spec-type mtp/draft-mtp). "
"False -> recommend `unsloth studio update`."
),
)
llama_cpp_prebuilt_stale: bool = Field(
False,
description = (
"Installed llama.cpp prebuilt is >=3 days behind the latest "
"release. True -> show `unsloth studio update` banner."
),
)
llama_cpp_installed_tag: Optional[str] = Field(
None,
description = "Installed llama.cpp tag, or None if unknown.",
)
llama_cpp_latest_tag: Optional[str] = Field(
None,
description = "Latest published llama.cpp tag, or None if GitHub unreachable.",
)
# =====================================================================
@ -393,15 +415,12 @@ ContentPart = Annotated[
class ChatMessage(BaseModel):
"""
A single message in the conversation.
"""Single message in a chat conversation.
``content`` may be a plain string (text-only) or a list of
content parts for multimodal messages (OpenAI vision format).
Assistant messages that only contain tool calls may set ``content``
to ``None`` with ``tool_calls`` populated. ``role="tool"`` messages
carry the result of a client-executed tool call and require
``tool_call_id`` per the OpenAI spec.
``content`` is a string or a list of multimodal content parts. Assistant
messages with only ``tool_calls`` populated may set ``content=None``.
Missing ``tool_call_id`` on ``role="tool"`` is resolved at the
``ChatCompletionRequest`` layer by walking back to the preceding assistant.
"""
role: Literal["system", "user", "assistant", "tool"] = Field(
@ -433,17 +452,11 @@ class ChatMessage(BaseModel):
raise ValueError('"name" is only valid on role="tool" messages.')
if self.role == "tool":
if not self.tool_call_id:
# Frontend's second-round POST drops the streamed id;
# synthesise one so the request round-trips.
import secrets as _secrets
self.tool_call_id = f"call_{_secrets.token_hex(8)}"
# tool_call_id resolution happens at ChatCompletionRequest scope.
if not self.content:
raise ValueError('role="tool" messages require non-empty "content".')
elif self.role == "assistant":
# Tolerate the post-Stop empty-assistant sentinel by
# collapsing content="" to None.
# Post-Stop sentinel: collapse content="" / [] to None.
if (self.content == "" or self.content == []) and not self.tool_calls:
self.content = None
else: # "user" | "system"
@ -616,6 +629,90 @@ class ChatCompletionRequest(BaseModel):
"OpenAI cloud + gpt-5.5 family path; ignored otherwise."
),
)
anthropic_code_exec_container_id: Optional[str] = Field(
None,
description = (
"[x-unsloth] Anthropic code_execution container id from the prior "
"response in the same chat thread. When set and `code_execution` "
"is in `enabled_tools`, the next /v1/messages call carries a "
"top-level `container` field so the model sees filesystem state "
"from earlier turns. Unset → Anthropic auto-creates a fresh "
"container. Stale ids surface a 4xx with a `container_expired` / "
"`container_not_found` hint; the backend emits a synthetic "
"`container_invalidated` _toolEvent so the next turn falls back "
"to auto-create."
),
)
@model_validator(mode = "after")
def _resolve_missing_tool_call_ids(self) -> "ChatCompletionRequest":
"""Fill missing tool_call_id by walking back to the preceding assistant.
OpenAI / Anthropic passthrough require the result id to match the
assistant's tool_calls[].id. Prefer function.name match, else first
unconsumed tool_call; synth random id only if no candidate exists.
Crossing a user turn breaks the lookup.
"""
# Pre-mark explicit ids first so a sibling missing-id result does not
# steal one already claimed by name.
consumed: set[tuple[int, int]] = set()
def _mark_consumed(start_idx: int, tool_call_id: str) -> None:
for asst_idx in range(start_idx - 1, -1, -1):
prev = self.messages[asst_idx]
if prev.role == "user":
break
if prev.role != "assistant" or not prev.tool_calls:
continue
for tc_idx, tc in enumerate(prev.tool_calls):
if isinstance(tc, dict) and tc.get("id") == tool_call_id:
consumed.add((asst_idx, tc_idx))
return
for tool_idx, msg in enumerate(self.messages):
if msg.role == "tool" and msg.tool_call_id:
_mark_consumed(tool_idx, msg.tool_call_id)
for tool_idx, msg in enumerate(self.messages):
if msg.role != "tool" or msg.tool_call_id:
continue
picked: str | None = None
for asst_idx in range(tool_idx - 1, -1, -1):
prev = self.messages[asst_idx]
if prev.role != "assistant" or not prev.tool_calls:
if prev.role == "user":
break
continue
name_match = None
fallback = None
for tc_idx, tc in enumerate(prev.tool_calls):
if (asst_idx, tc_idx) in consumed:
continue
if not isinstance(tc, dict):
continue
tc_id = tc.get("id")
if not tc_id:
continue
function = tc.get("function")
function_name = (
function.get("name") if isinstance(function, dict) else None
)
if msg.name and function_name == msg.name:
name_match = (tc_id, asst_idx, tc_idx)
break
if fallback is None:
fallback = (tc_id, asst_idx, tc_idx)
chosen = name_match or fallback
if chosen is not None:
picked, a, t = chosen
consumed.add((a, t))
break
if picked is None:
import secrets as _secrets
picked = f"call_{_secrets.token_hex(8)}"
msg.tool_call_id = picked
return self
# ── OpenAI shell-tool container management ─────────────────────

View file

@ -7,6 +7,8 @@ Authentication API routes
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
import ipaddress
import os
import threading
import time
from collections import deque
@ -36,47 +38,155 @@ from auth.authentication import (
router = APIRouter()
# In-memory per-IP login rate limiter; multi-process deployment needs a shared store.
_LOGIN_BUCKETS: dict[str, deque] = {}
# Per-(ip, username) bucket + per-IP aggregate. Account bucket stops one user's
# typos from blocking others; the aggregate stops username-rotation spray.
# Single-process only -- multi-worker deployments need a shared store.
_LOGIN_BUCKETS: dict[tuple[str, str], deque] = {}
_LOGIN_IP_BUCKETS: dict[str, deque] = {}
_LOGIN_BUCKETS_LOCK = threading.Lock()
_LOGIN_WINDOW_SECONDS = 60.0
_LOGIN_MAX_FAILS = 5
_LOGIN_IP_MAX_FAILS = 30
_LOGIN_LOCKOUT_SECONDS = 60
# Bucket-dict cap. On overflow we prune stale entries; if still full the
# failure folds into the per-IP aggregate only.
_LOGIN_MAX_BUCKETS = 4096
# Unrepresentable as a real username (leading NUL); folds unknown-user attempts
# into one slot so attacker cardinality cannot blow the bucket dict.
_UNKNOWN_LOGIN_USER = "\x00unknown-user"
def _client_key(request: Request | None) -> str:
if request is None or request.client is None:
def _trust_forwarded_for() -> bool:
"""Honour X-Forwarded-For only when UNSLOTH_STUDIO_TRUST_FORWARDED is set.
Off by default so a direct caller cannot spoof the header.
"""
return os.environ.get("UNSLOTH_STUDIO_TRUST_FORWARDED", "").lower() in (
"1",
"true",
"yes",
)
def _normalize_forwarded_addr(value: str) -> str:
"""Parse an XFF / Forwarded `for=` value into a bare IP (port-stripped)."""
value = (value or "").strip().strip('"')
if not value or value.lower() == "unknown":
return ""
if value.startswith("["):
# Bracketed IPv6, optionally with port.
end = value.find("]")
if end <= 0:
return ""
host = value[1:end]
elif value.count(":") == 1:
# IPv4:port. Bare IPv6 has multiple colons and takes the else branch.
head, _, tail = value.rpartition(":")
host = head if tail.isdigit() and head else value
else:
host = value
try:
return str(ipaddress.ip_address(host))
except ValueError:
return ""
def _forwarded_for_from_element(element: str) -> str:
"""Pick the `for=` token out of a single ``Forwarded`` element."""
for tok in element.split(";"):
key, sep, val = tok.strip().partition("=")
if sep and key.lower() == "for":
return _normalize_forwarded_addr(val)
return ""
def _client_ip(request: Request | None) -> str:
if request is None:
return "_unknown"
return request.client.host or "_unknown"
if _trust_forwarded_for():
xff = request.headers.get("x-forwarded-for", "")
if xff:
# First entry is the originating client.
normalized = _normalize_forwarded_addr(xff.split(",", 1)[0])
if normalized:
return normalized
fwd = request.headers.get("forwarded", "")
if fwd:
# First element only -- multi-element headers cannot fork buckets.
normalized = _forwarded_for_from_element(fwd.split(",", 1)[0])
if normalized:
return normalized
return (request.client.host if request.client else None) or "_unknown"
def _record_login_failure(ip: str) -> int:
def _bucket_key(request: Request | None, username: str) -> tuple[str, str]:
return (_client_ip(request), (username or "").casefold())
def _unknown_user_key(request: Request | None) -> tuple[str, str]:
return (_client_ip(request), _UNKNOWN_LOGIN_USER)
def _prune_bucket(bucket: deque, now: float) -> None:
while bucket and now - bucket[0] > _LOGIN_WINDOW_SECONDS:
bucket.popleft()
def _prune_stale_buckets(now: float) -> None:
"""Drop empty / expired account buckets to bound memory under spray."""
stale: list[tuple[str, str]] = []
for key, bucket in _LOGIN_BUCKETS.items():
_prune_bucket(bucket, now)
if not bucket:
stale.append(key)
for key in stale:
_LOGIN_BUCKETS.pop(key, None)
def _record_login_failure(key: tuple[str, str]) -> int:
now = time.monotonic()
ip, _username = key
with _LOGIN_BUCKETS_LOCK:
bucket = _LOGIN_BUCKETS.setdefault(ip, deque())
while bucket and now - bucket[0] > _LOGIN_WINDOW_SECONDS:
bucket.popleft()
bucket.append(now)
return len(bucket)
ip_bucket = _LOGIN_IP_BUCKETS.setdefault(ip, deque())
_prune_bucket(ip_bucket, now)
ip_bucket.append(now)
if key not in _LOGIN_BUCKETS and len(_LOGIN_BUCKETS) >= _LOGIN_MAX_BUCKETS:
_prune_stale_buckets(now)
if key in _LOGIN_BUCKETS or len(_LOGIN_BUCKETS) < _LOGIN_MAX_BUCKETS:
account_bucket = _LOGIN_BUCKETS.setdefault(key, deque())
_prune_bucket(account_bucket, now)
account_bucket.append(now)
return len(account_bucket)
# Bucket dict is at its cap; per-IP cap still applies via ip_bucket.
return len(ip_bucket)
def _login_blocked(ip: str) -> int:
def _blocked_for(bucket: deque | None, now: float, max_fails: int) -> int:
if not bucket:
return 0
_prune_bucket(bucket, now)
if len(bucket) >= max_fails:
return max(1, int(_LOGIN_WINDOW_SECONDS - (now - bucket[0])))
return 0
def _login_blocked(key: tuple[str, str]) -> int:
"""Return seconds until the next attempt is allowed, or 0."""
now = time.monotonic()
ip, _username = key
with _LOGIN_BUCKETS_LOCK:
bucket = _LOGIN_BUCKETS.get(ip)
if not bucket:
return 0
while bucket and now - bucket[0] > _LOGIN_WINDOW_SECONDS:
bucket.popleft()
if len(bucket) >= _LOGIN_MAX_FAILS:
return max(1, int(_LOGIN_WINDOW_SECONDS - (now - bucket[0])))
return 0
return max(
_blocked_for(_LOGIN_BUCKETS.get(key), now, _LOGIN_MAX_FAILS),
_blocked_for(_LOGIN_IP_BUCKETS.get(ip), now, _LOGIN_IP_MAX_FAILS),
)
def _clear_login_bucket(ip: str) -> None:
def _clear_login_bucket(key: tuple[str, str]) -> None:
ip, _username = key
with _LOGIN_BUCKETS_LOCK:
_LOGIN_BUCKETS.pop(ip, None)
_LOGIN_BUCKETS.pop(key, None)
_LOGIN_IP_BUCKETS.pop(ip, None)
@router.get("/status", response_model = AuthStatusResponse)
@ -95,14 +205,17 @@ async def auth_status() -> AuthStatusResponse:
@router.post("/login", response_model = Token)
async def login(payload: AuthLoginRequest, request: Request) -> Token:
"""Login with username/password. Rate-limited per source IP."""
ip = _client_key(request)
blocked_for = _login_blocked(ip)
"""Login with username/password. Per-account + per-IP rate-limited."""
key = _bucket_key(request, payload.username)
unknown_key = _unknown_user_key(request)
blocked_for = max(_login_blocked(key), _login_blocked(unknown_key))
if blocked_for > 0:
raise HTTPException(
status_code = status.HTTP_429_TOO_MANY_REQUESTS,
# IP is intentionally not interpolated into the body; behind a
# proxy or NAT it is either misleading or an info leak.
detail = (
f"Too many failed login attempts from {ip}. "
f"Too many failed login attempts. "
f"Try again in {blocked_for} seconds."
),
headers = {"Retry-After": str(blocked_for)},
@ -110,7 +223,9 @@ async def login(payload: AuthLoginRequest, request: Request) -> Token:
record = storage.get_user_and_secret(payload.username)
if record is None:
_record_login_failure(ip)
# Record under a single sentinel key per IP so attacker-controlled
# username cardinality does not allocate buckets without bound.
_record_login_failure(unknown_key)
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Incorrect password. Run 'unsloth studio reset-password' in your terminal to reset it.",
@ -118,13 +233,14 @@ async def login(payload: AuthLoginRequest, request: Request) -> Token:
salt, pwd_hash, _jwt_secret, must_change_password = record
if not hashing.verify_password(payload.password, salt, pwd_hash):
_record_login_failure(ip)
_record_login_failure(key)
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Incorrect password. Run 'unsloth studio reset-password' in your terminal to reset it.",
)
_clear_login_bucket(ip)
_clear_login_bucket(key)
_clear_login_bucket(unknown_key)
access_token = create_access_token(subject = payload.username)
refresh_token = create_refresh_token(subject = payload.username)
return Token(

View file

@ -117,9 +117,13 @@ try:
LlamaCppBackend,
_DEFAULT_MAX_TOKENS_FLOOR,
_DEFAULT_T_MAX_PREDICT_MS,
_hf_offline_if_dns_dead,
detect_reasoning_flags,
)
from core.inference.llama_server_args import validate_extra_args
from core.inference.llama_server_args import (
strip_shadowing_flags,
validate_extra_args,
)
from utils.models import ModelConfig
from utils.inference import load_inference_config
from utils.models.model_config import load_model_defaults
@ -139,9 +143,13 @@ except ImportError:
LlamaCppBackend,
_DEFAULT_MAX_TOKENS_FLOOR,
_DEFAULT_T_MAX_PREDICT_MS,
_hf_offline_if_dns_dead,
detect_reasoning_flags,
)
from core.inference.llama_server_args import validate_extra_args
from core.inference.llama_server_args import (
strip_shadowing_flags,
validate_extra_args,
)
from utils.models import ModelConfig
from utils.inference import load_inference_config
from utils.models.model_config import load_model_defaults
@ -406,6 +414,57 @@ def _validate_native_mmproj_companion(
) from exc
def _normalise_settings_str(value: Optional[str]) -> Optional[str]:
"""Lowercase + strip a settings string, mapping blank/None to None."""
if value is None:
return None
if isinstance(value, str):
stripped = value.strip().lower()
return stripped or None
return value
def _request_matches_loaded_settings(
request: LoadRequest, llama_backend: LlamaCppBackend
) -> bool:
"""True iff every runtime setting on the request matches the loaded
server. Caller has already checked model+variant+is_loaded. See #5401."""
# Compare requested n_ctx (not effective) so VRAM-cap doesn't mask
# an Auto-vs-explicit slider flip.
if request.max_seq_length != llama_backend.requested_n_ctx:
return False
if _normalise_settings_str(request.cache_type_kv) != _normalise_settings_str(
llama_backend.cache_type_kv
):
return False
# Vision loads silently drop speculative decoding (llama_cpp.py gates
# spec on ``not is_vision``), so treat the request as ``off`` against
# the backend's ``None`` to avoid forcing a redundant reload.
if llama_backend.is_vision:
req_spec = "off"
else:
req_spec = _normalise_settings_str(request.speculative_type) or "off"
backend_spec = _normalise_settings_str(llama_backend.speculative_type) or "off"
if req_spec != backend_spec:
return False
if (request.chat_template_override or None) != (
llama_backend.chat_template_override or None
):
return False
# llama_extra_args=None means "inherit"; only an explicit list that
# differs forces a reload. On the inherit path, refuse to match if
# stored extras contain any shadow flag, so the reload path can
# strip them instead of leaving a stale override in effect.
backend_extra = list(llama_backend.extra_args) if llama_backend.extra_args else []
if request.llama_extra_args is None:
if backend_extra and strip_shadowing_flags(backend_extra) != backend_extra:
return False
else:
if list(request.llama_extra_args) != backend_extra:
return False
return True
def _resolve_model_identifier_for_request(
request: LoadRequest | ValidateModelRequest,
*,
@ -461,6 +520,11 @@ async def load_model(
extra_llama_args = validate_extra_args(request.llama_extra_args)
except ValueError as exc:
raise HTTPException(status_code = 400, detail = str(exc))
# Re-narrow []-from-None back to None so the inheritance path
# below can tell "caller omitted" from "caller explicit []".
extra_llama_args: Optional[list[str]] = (
None if request.llama_extra_args is None else extra_llama_args
)
model_identifier, model_log_label, native_grant_backed = (
_resolve_model_identifier_for_request(request, operation = "load-model")
@ -479,6 +543,9 @@ async def load_model(
and llama_backend.hf_variant.lower() == request.gguf_variant.lower()
and llama_backend.model_identifier
and llama_backend.model_identifier.lower() == model_identifier.lower()
# Also require runtime settings to match so Apply changes
# aren't silently dropped (#5401).
and _request_matches_loaded_settings(request, llama_backend)
):
logger.info(
f"Model already loaded (GGUF): {model_log_label} variant={request.gguf_variant}, skipping reload"
@ -578,13 +645,15 @@ async def load_model(
chat_template = _chat_template,
)
# Create config using clean factory method
# is_lora is auto-detected from adapter_config.json on disk/HF
config = ModelConfig.from_identifier(
model_id = model_identifier,
hf_token = request.hf_token,
gguf_variant = request.gguf_variant,
)
# is_lora auto-detected from adapter_config.json on disk/HF.
# DNS-probe wrap so offline loads skip 30-60s of soft-failed
# network checks before the worker starts.
with _hf_offline_if_dns_dead():
config = ModelConfig.from_identifier(
model_id = model_identifier,
hf_token = request.hf_token,
gguf_variant = request.gguf_variant,
)
if not config:
raise HTTPException(
@ -613,6 +682,70 @@ async def load_model(
)
unsloth_backend.unload_model(unsloth_backend.active_model_name)
# Inherit llama_extra_args from the previous load when the
# request omits the field (the chat-settings Apply path
# does not round-trip them; explicit [] still clears).
# Inheritance is gated on (model_identifier, hf_variant)
# to refuse cross-model pickup, and shadowing flags are
# stripped so an inherited override can't win the last-wins
# CLI parse against a freshly-supplied first-class field.
if request.llama_extra_args is None and llama_backend.extra_args:
source = llama_backend.extra_args_source
# Compare against the resolved variant, not the request
# field: callers commonly omit gguf_variant for local
# ``.gguf`` paths and HF auto-pick flows. ``config.gguf_
# variant`` is the variant load_model was actually
# invoked with (see the HF / local branches below), so
# both sides of the comparison key off the same string.
resolved_variant = config.gguf_variant
same_source = bool(
source
and source[0]
and source[0].lower() == model_identifier.lower()
and (source[1] or "").lower() == (resolved_variant or "").lower()
)
if not same_source:
logger.info(
"Not inheriting llama_extra_args: stored args came "
"from %s, loading %s",
source,
(model_identifier, resolved_variant),
)
# Cross-model: clear explicitly so the backend
# doesn't inherit via "no opinion" semantics.
extra_llama_args = []
else:
# Strip only the groups whose first-class field
# was actually set by the caller, so an inherited
# --chat-template-file survives an Apply that omits
# chat_template_override.
fields_set = getattr(request, "model_fields_set", set())
stripped = strip_shadowing_flags(
llama_backend.extra_args,
strip_context = "max_seq_length" in fields_set,
strip_cache = "cache_type_kv" in fields_set,
strip_spec = "speculative_type" in fields_set,
strip_template = "chat_template_override" in fields_set,
)
try:
extra_llama_args = validate_extra_args(stripped)
except ValueError:
# Should not happen on already-validated args; degrade
# to no-extras rather than 400 if managed flags changed.
logger.warning(
"Stored llama_extra_args failed revalidation; "
"loading without them: %s",
stripped,
)
extra_llama_args = []
else:
if extra_llama_args:
logger.info(
"Inheriting llama_extra_args from previous "
"load (same model, shadow-stripped): %s",
extra_llama_args,
)
# Route to HF mode or local mode based on config
# Run in a thread so the event loop stays free for progress
# polling and other requests during the (potentially long)
@ -645,6 +778,10 @@ async def load_model(
llama_backend.load_model,
gguf_path = config.gguf_file,
mmproj_path = config.gguf_mmproj_file,
# Pass the resolved variant so _extra_args_source
# is keyed off the same string the inheritance
# check at the top of /load uses (#5401 followup).
hf_variant = config.gguf_variant,
model_identifier = config.identifier,
is_vision = config.is_vision,
n_ctx = request.max_seq_length,
@ -689,7 +826,7 @@ async def load_model(
display_name = model_log_label
if native_grant_backed
else config.display_name,
is_vision = config.is_vision,
is_vision = llama_backend.is_vision,
is_lora = False,
is_gguf = True,
is_audio = _gguf_is_audio,
@ -1149,6 +1286,24 @@ async def get_status(
try:
llama_backend = get_llama_cpp_backend()
# MTP probe + freshness check (both cached). Drive the UI banner.
try:
_bin = type(llama_backend)._find_llama_server_binary()
_caps = type(llama_backend).probe_server_capabilities(_bin)
_supports_mtp = bool(_caps.get("supports_mtp", False))
except Exception:
_bin = None
_supports_mtp = True # fail open
try:
from utils.llama_cpp_freshness import check_prebuilt_freshness
_freshness = check_prebuilt_freshness(_bin)
except Exception:
_freshness = {}
_stale = bool(_freshness.get("stale"))
_installed_tag = _freshness.get("installed_tag")
_latest_tag = _freshness.get("latest_tag")
# If a GGUF model is loaded via llama-server, report that
if llama_backend.is_loaded:
_model_id = llama_backend.model_identifier
@ -1191,6 +1346,10 @@ async def get_status(
cache_type_kv = llama_backend.cache_type_kv,
chat_template_override = llama_backend.chat_template_override,
speculative_type = llama_backend.speculative_type,
llama_cpp_supports_mtp = _supports_mtp,
llama_cpp_prebuilt_stale = _stale,
llama_cpp_installed_tag = _installed_tag,
llama_cpp_latest_tag = _latest_tag,
)
# Otherwise, report Unsloth backend status
@ -1251,6 +1410,10 @@ async def get_status(
supports_preserve_thinking = False,
supports_tools = False,
chat_template = chat_template,
llama_cpp_supports_mtp = _supports_mtp,
llama_cpp_prebuilt_stale = _stale,
llama_cpp_installed_tag = _installed_tag,
llama_cpp_latest_tag = _latest_tag,
)
except Exception as e:
@ -1604,6 +1767,7 @@ async def _proxy_to_external_provider(
enabled_tools = payload.enabled_tools,
enable_prompt_caching = payload.enable_prompt_caching,
openai_code_exec_container_id = payload.openai_code_exec_container_id,
anthropic_code_exec_container_id = payload.anthropic_code_exec_container_id,
stream = payload.stream,
)
try:
@ -1825,8 +1989,11 @@ async def openai_chat_completions(
Supports multimodal messages: ``content`` may be a plain string or a
list of content parts (``text`` / ``image_url``).
Streaming (default): returns SSE chunks matching OpenAI's format.
Non-streaming: returns a single ChatCompletion JSON object.
Non-streaming (default): returns a single ChatCompletion JSON object.
Streaming: returns SSE chunks matching OpenAI's format.
``stream`` defaults to ``false`` to match OpenAI's spec; clients opt
into SSE by sending ``stream: true``.
Automatically routes to the correct backend:
- GGUF models llama-server via LlamaCppBackend
@ -2050,6 +2217,9 @@ async def openai_chat_completions(
cancel_event = threading.Event()
completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
# `stream` defaults to False on ChatCompletionRequest (OpenAI spec
# parity). Naive curl / .NET / System.Text.Json clients omitting
# the field used to get SSE here and choke on deserialization (#5047).
if payload.stream:
return await _openai_passthrough_stream(
request,
@ -3405,6 +3575,17 @@ async def _responses_stream(
),
)
# Direct pass-through bypasses the openai_chat_completions image gate.
if not llama_backend.is_vision and any(
isinstance(m.content, list)
and any(isinstance(p, ImageContentPart) for p in m.content)
for m in messages
):
raise HTTPException(
status_code = 400,
detail = "Image provided but current GGUF model does not support vision.",
)
body = _build_openai_passthrough_body(
chat_req, backend_ctx = llama_backend.context_length
)

View file

@ -26,6 +26,22 @@ def _is_valid_repo_id(repo_id: str) -> bool:
return bool(_VALID_REPO_ID.fullmatch(repo_id))
def _safe_is_dir(path) -> bool:
"""``Path.is_dir()`` that returns ``False`` instead of raising.
On Python >= 3.12 ``is_dir()``'s ``os.stat`` only suppresses
"not found"-class errors and now propagates ``PermissionError``
(EACCES); on Python <= 3.11 it returned ``False``. The folder-scan
endpoints probe well-known system locations (e.g. a root-owned,
mode-700 ``/usr/share/ollama/.ollama/models``) and must treat an
un-stat-able path as "not a directory", never 500.
"""
try:
return Path(path).is_dir()
except OSError:
return False
# Add backend directory to path
backend_path = Path(__file__).parent.parent.parent
if str(backend_path) not in sys.path:
@ -882,7 +898,7 @@ async def get_recommended_folders(
return
if resolved in seen:
return
if Path(resolved).is_dir() and os.access(resolved, os.R_OK | os.X_OK):
if _safe_is_dir(resolved) and os.access(resolved, os.R_OK | os.X_OK):
seen.add(resolved)
folders.append(resolved)
@ -1056,7 +1072,7 @@ def _build_browse_allowlist() -> list[Path]:
resolved = p.resolve()
except OSError:
return
if resolved.is_dir():
if _safe_is_dir(resolved):
candidates.append(resolved)
_add(Path.home())
@ -1389,7 +1405,7 @@ async def browse_folders(
return
if resolved in seen_sug:
return
if Path(resolved).is_dir():
if _safe_is_dir(resolved):
seen_sug.add(resolved)
suggestions.append(resolved)

View file

@ -24,7 +24,7 @@ if str(backend_dir) not in sys.path:
import _platform_compat # noqa: F401
from loggers import get_logger
from startup_banner import print_studio_access_banner
from startup_banner import print_studio_access_banner, print_studio_stop_hint
logger = get_logger(__name__)
@ -74,6 +74,255 @@ def _resolve_external_ip() -> str:
return "0.0.0.0"
def _install_uvicorn_startup_log_rewrite(bind_host: str, display_host: str) -> None:
"""Rewrite Uvicorn's startup log line: swap wildcard bind for the
externally-reachable address, replace the CTRL+C suffix with our Mac-aware
stop hint, and rename the prefix to "Unsloth Studio running on"."""
import logging
import re
rewrite_host = (
bind_host in ("0.0.0.0", "::")
and bool(display_host)
and display_host != bind_host
)
new_suffix = "(To stop: press Ctrl+C -- on macOS, Control+C not Command+C)"
old_suffix_re = re.compile(r"\(Press CTRL\+C to quit\)")
old_prefix = "Uvicorn running on "
new_prefix = "Unsloth Studio running on "
def _rewrite(text: str) -> str:
if text.startswith(old_prefix):
text = new_prefix + text[len(old_prefix) :]
return old_suffix_re.sub(new_suffix, text)
class _UvicornStartupRewrite(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
try:
msg = record.msg if isinstance(record.msg, str) else ""
if (
msg.startswith(old_prefix)
and isinstance(record.args, tuple)
and len(record.args) >= 3
):
if rewrite_host and record.args[1] == bind_host:
record.args = (
record.args[0],
display_host,
record.args[2],
*record.args[3:],
)
record.msg = _rewrite(msg)
cmsg = getattr(record, "color_message", None)
if isinstance(cmsg, str):
record.color_message = _rewrite(cmsg)
except Exception:
pass
return True
f = _UvicornStartupRewrite()
for name in ("uvicorn", "uvicorn.error"):
logging.getLogger(name).addFilter(f)
def _local_port_open(host: str, port: int, timeout: float = 1.0) -> bool:
"""Return True iff a TCP connection to (host, port) succeeds within timeout."""
import socket
try:
with socket.create_connection((host, port), timeout = timeout):
return True
except OSError:
return False
def _working_local_url(port: int) -> "str | None":
"""Return a working loopback URL on this machine, or None if neither
127.0.0.1 nor ::1 responds. Used as a fallback when external reachability fails."""
if _local_port_open("127.0.0.1", port):
return f"http://127.0.0.1:{port}"
if _local_port_open("::1", port):
return f"http://[::1]:{port}"
return None
def _stdout_color_ok() -> bool:
"""Whether to emit ANSI color codes on stdout. Mirrors startup_banner."""
if os.environ.get("NO_COLOR", "").strip():
return False
if os.environ.get("FORCE_COLOR", "").strip():
return True
try:
return sys.stdout.isatty()
except (AttributeError, OSError, ValueError):
return False
def _verify_global_reachability(display_host: str, port: int) -> None:
"""Probe check-host.net to confirm display_host:port is reachable from the
public internet. Synchronous so the caller can render output between the
banner URL section and the trailing stop hint. Bounded at ~15s; failures
are swallowed (the verifier failing is not Studio failing). Only meaningful
when bound to a wildcard host."""
import ipaddress
import json
import time
import urllib.error
import urllib.parse
import urllib.request
if not display_host or display_host in ("0.0.0.0", "::"):
return
use_color = _stdout_color_ok()
dim = "\033[38;5;245m" if use_color else ""
ok_c = "\033[38;5;120;1m" if use_color else ""
err_c = "\033[38;5;203;1m" if use_color else ""
warn_c = "\033[38;5;215;1m" if use_color else ""
local_url_c = "\033[38;5;108;1m" if use_color else "" # matches banner's URL color
reset = "\033[0m" if use_color else ""
url = f"http://{display_host}:{port}"
# Private / loopback / link-local addresses are not globally routable.
try:
addr = ipaddress.ip_address(display_host)
if addr.is_loopback or addr.is_private or addr.is_link_local:
print(
f"{dim} Note: {display_host} is a private/LAN address -- "
f"reachable on this network only, not from the public internet."
f"{reset}",
flush = True,
)
return
except ValueError:
# Not an IP literal; probe by hostname.
pass
try:
qs = urllib.parse.urlencode({"host": f"{display_host}:{port}", "max_nodes": 3})
req = urllib.request.Request(
f"https://check-host.net/check-tcp?{qs}",
headers = {
"Accept": "application/json",
"User-Agent": "unsloth-studio-reachability/1",
},
)
with urllib.request.urlopen(req, timeout = 5) as resp:
init = json.loads(resp.read().decode("utf-8", errors = "replace"))
req_id = init.get("request_id")
if not req_id:
return
results = {}
deadline = time.monotonic() + 15.0
poll_req = urllib.request.Request(
f"https://check-host.net/check-result/{req_id}",
headers = {
"Accept": "application/json",
"User-Agent": "unsloth-studio-reachability/1",
},
)
while time.monotonic() < deadline:
time.sleep(1.5)
try:
with urllib.request.urlopen(poll_req, timeout = 5) as resp:
results = json.loads(resp.read().decode("utf-8", errors = "replace"))
except Exception:
continue
if results and all(v is not None for v in results.values()):
break
# Two decisive nodes is enough; stop polling early.
decisive = [
v
for v in results.values()
if isinstance(v, list)
and v
and isinstance(v[0], dict)
and ("time" in v[0] or "error" in v[0])
]
if len(decisive) >= 2:
break
ok_nodes = err_nodes = 0
for v in results.values():
if not isinstance(v, list) or not v or not isinstance(v[0], dict):
continue
if "time" in v[0]:
ok_nodes += 1
elif "error" in v[0]:
err_nodes += 1
total = ok_nodes + err_nodes
print("", flush = True)
if ok_nodes:
print(
f"{ok_c} Reachability check: {url}/ is reachable from the "
f"public internet ({ok_nodes}/{total} probe nodes connected).{reset}",
flush = True,
)
elif err_nodes:
print(
f"{err_c} Reachability check: {url}/ is NOT reachable from "
f"the public internet ({err_nodes}/{total} probe nodes failed).{reset}",
flush = True,
)
print(f"{dim} Common causes:{reset}", flush = True)
print(
f"{dim} * AWS -- the instance's Security Group doesn't "
f"allow inbound TCP {port}.{reset}",
flush = True,
)
print(
f"{dim} * GCP -- no firewall rule allowing TCP {port} "
f"for the instance's network tag.{reset}",
flush = True,
)
print(
f"{dim} * Azure / other clouds -- equivalent NSG / "
f"firewall rule missing.{reset}",
flush = True,
)
print(
f"{dim} * Home -- your router isn't port-forwarding "
f"{port} to this machine.{reset}",
flush = True,
)
print(
f"{dim} Workaround that needs no firewall changes -- "
f"SSH local-forward from your laptop:{reset}",
flush = True,
)
print(
f"{dim} ssh -L {port}:localhost:{port} "
f"<user>@{display_host}{reset}",
flush = True,
)
print(
f"{dim} then open http://localhost:{port}/ in your browser.{reset}",
flush = True,
)
# Only offer the local URL if loopback actually answers.
local_url = _working_local_url(port)
if local_url:
print(
f"{local_url_c} You can access Unsloth Studio locally "
f"in the meantime: {local_url}{reset}",
flush = True,
)
else:
print(
f"{warn_c} Reachability check: probe nodes did not respond "
f"in time -- could not verify {url}/.{reset}",
flush = True,
)
except urllib.error.URLError:
# Outbound HTTPS blocked; skip silently.
pass
except Exception:
pass
def _get_pid_on_port(port: int) -> "tuple[int, str] | None":
"""Return (pid, process_name) of the process listening on *port*, or None.
@ -344,6 +593,10 @@ def run_server(
if not silent:
print(f"[WARNING] Frontend not found at {frontend_path}")
# Resolve once; shared by the log rewrite and the banner.
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
_install_uvicorn_startup_log_rewrite(host, display_host)
ready_event = Event()
startup_failed = Event()
startup_errors = []
@ -426,12 +679,18 @@ def run_server(
print(f"TAURI_PORT={port}", flush = True)
if not silent:
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
wildcard_bind = host in ("0.0.0.0", "::")
# For wildcard binds, run the reachability check between the URL
# section and the stop hint so the stop hint stays last on screen.
print_studio_access_banner(
port = port,
bind_host = host,
display_host = display_host,
include_stop_hint = not wildcard_bind,
)
if wildcard_bind:
_verify_global_reachability(display_host, port)
print_studio_stop_hint()
return app

View file

@ -33,18 +33,49 @@ def print_port_in_use_notice(original_port: int, new_port: int) -> None:
print(msg)
def print_studio_stop_hint() -> None:
"""Print the trailing stop hint + closing divider. Separate from the main
banner so callers can interleave content (e.g. a reachability check)."""
use_color = stdout_supports_color()
dim = "\033[38;5;245m"
stop_hint_style = "\033[38;5;215;1m"
reset = "\033[0m"
def style(text: str, code: str) -> str:
return f"{code}{text}{reset}" if use_color else text
print(
"\n".join(
[
"",
style(
" To stop Unsloth Studio: press Ctrl+C in this terminal.",
stop_hint_style,
),
style(" (On macOS this is Control+C, not Command+C.)", dim),
style("" * 52, dim),
"",
]
)
)
def print_studio_access_banner(
*,
port: int,
bind_host: str,
display_host: str,
include_stop_hint: bool = True,
) -> None:
"""Pretty-print URLs after the server is listening (beginner-friendly)."""
"""Pretty-print URLs after the server is listening. Set
``include_stop_hint=False`` to omit the trailing stop block; pair with
:func:`print_studio_stop_hint` after inserting your own content."""
use_color = stdout_supports_color()
dim = "\033[38;5;245m"
title = "\033[38;5;150m"
local_url_style = "\033[38;5;108;1m"
secondary = "\033[38;5;109m"
stop_hint_style = "\033[38;5;215;1m"
reset = "\033[0m"
def style(text: str, code: str) -> str:
@ -116,8 +147,48 @@ def print_studio_access_banner(
f" Tip: if you are on this computer, open {tip_url}/ in your browser.",
dim,
),
"",
]
)
if loopback_bind and not listen_all:
lines.extend(
[
"",
style(
" Studio is only reachable on this machine (bound to 127.0.0.1).",
secondary,
),
style(
" To deploy and access globally:",
secondary,
),
style(
" 1. press Ctrl+C to stop Studio",
secondary,
),
style(
f" 2. relaunch with: unsloth studio -H 0.0.0.0 -p {port}",
secondary,
),
style(
" Only do this on trusted networks -- it exposes the API on every interface.",
secondary,
),
]
)
if include_stop_hint:
lines.extend(
[
"",
style(
" To stop Unsloth Studio: press Ctrl+C in this terminal.",
stop_hint_style,
),
style(" (On macOS this is Control+C, not Command+C.)", dim),
style("" * 52, dim),
"",
]
)
print("\n".join(lines))

View file

@ -0,0 +1,180 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Tests for core/training/training.py:_cleanup_cancelled_checkpoints."""
import os
import sys
from pathlib import Path
import pytest
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
@pytest.fixture
def outputs_setup(tmp_path, monkeypatch):
"""Point outputs_root() at a temp dir so cleanup is allowed to run on it.
The training module binds ``outputs_root`` at import time
(``from utils.paths import outputs_root``), so we have to patch
the symbol on the importer module, not on storage_roots.
"""
from core.training import training as training_mod
monkeypatch.setattr(training_mod, "outputs_root", lambda: tmp_path)
return tmp_path
def _mk_dir(parent: Path, name: str) -> Path:
p = parent / name
p.mkdir()
(p / "marker.txt").write_text(name)
return p
def test_completed_checkpoints_are_preserved(outputs_setup):
"""The big regression: prior to this fix, every completed
checkpoint-N/ was rmtree'd on Cancel, destroying resume points."""
from core.training.training import _cleanup_cancelled_checkpoints
out = outputs_setup / "run-1"
out.mkdir()
ckpts = [_mk_dir(out, f"checkpoint-{n}") for n in (200, 400, 600)]
tmp = _mk_dir(out, "tmp-checkpoint-800")
_cleanup_cancelled_checkpoints(out)
for c in ckpts:
assert c.exists(), f"completed {c.name} was destroyed"
assert (c / "marker.txt").exists()
assert not tmp.exists(), "in-flight tmp-checkpoint-800 should be removed"
def test_in_flight_tmp_checkpoints_removed(outputs_setup):
from core.training.training import _cleanup_cancelled_checkpoints
out = outputs_setup / "run-2"
out.mkdir()
_mk_dir(out, "tmp-checkpoint-100")
_mk_dir(out, "tmp-checkpoint-200")
_mk_dir(out, "checkpoint-50") # completed, kept
_cleanup_cancelled_checkpoints(out)
assert not (out / "tmp-checkpoint-100").exists()
assert not (out / "tmp-checkpoint-200").exists()
assert (out / "checkpoint-50").exists()
def test_non_checkpoint_dirs_left_alone(outputs_setup):
from core.training.training import _cleanup_cancelled_checkpoints
out = outputs_setup / "run-3"
out.mkdir()
_mk_dir(out, "logs")
_mk_dir(out, "tensorboard")
_mk_dir(out, "checkpoint-final") # non-int suffix, kept
_mk_dir(out, "checkpoint-best")
_mk_dir(out, "tmp-checkpoint-99")
_cleanup_cancelled_checkpoints(out)
for n in ("logs", "tensorboard", "checkpoint-final", "checkpoint-best"):
assert (out / n).exists(), f"{n} should be preserved"
assert not (out / "tmp-checkpoint-99").exists()
def test_output_dir_outside_outputs_root_is_refused(tmp_path, monkeypatch):
"""Containment check: even if a bug passed an output_dir outside
outputs_root, the cleanup must refuse to touch it."""
from core.training import training as training_mod
from core.training.training import _cleanup_cancelled_checkpoints
inside = tmp_path / "inside"
inside.mkdir()
monkeypatch.setattr(training_mod, "outputs_root", lambda: inside)
outside = tmp_path / "outside"
outside.mkdir()
_mk_dir(outside, "tmp-checkpoint-1")
_cleanup_cancelled_checkpoints(outside)
assert (
outside / "tmp-checkpoint-1"
).exists(), "must not rmtree under a path outside outputs_root"
def test_symlinked_output_dir_skipped(outputs_setup):
"""A symlinked output_dir is skipped so the realpath check can't be
leveraged to delete content via a symlink trick."""
from core.training.training import _cleanup_cancelled_checkpoints
real = outputs_setup / "real-run"
real.mkdir()
_mk_dir(real, "tmp-checkpoint-1")
link = outputs_setup / "link-run"
try:
link.symlink_to(real, target_is_directory = True)
except (OSError, NotImplementedError):
pytest.skip("symlinks not supported on this filesystem / platform")
_cleanup_cancelled_checkpoints(link)
assert (real / "tmp-checkpoint-1").exists(), "symlinked output_dir must be skipped"
def test_missing_output_dir_is_noop(outputs_setup):
from core.training.training import _cleanup_cancelled_checkpoints
_cleanup_cancelled_checkpoints(outputs_setup / "does-not-exist")
# Should not raise; nothing to assert beyond non-failure.
def test_symlinked_child_skipped(outputs_setup):
"""A symlinked tmp-checkpoint-* child must not be deleted, so the
realpath bypass cannot redirect rmtree to arbitrary content."""
from core.training.training import _cleanup_cancelled_checkpoints
out = outputs_setup / "run-symchild"
out.mkdir()
target = outputs_setup / "external"
target.mkdir()
(target / "important.txt").write_text("keep me")
link = out / "tmp-checkpoint-99"
try:
link.symlink_to(target, target_is_directory = True)
except (OSError, NotImplementedError):
pytest.skip("symlinks not supported on this filesystem / platform")
_cleanup_cancelled_checkpoints(out)
assert (
target / "important.txt"
).exists(), "symlink target outside outputs_root must not be rmtree'd"
def test_non_numeric_tmp_checkpoint_suffix_preserved(outputs_setup):
"""HF Trainer's partials are tmp-checkpoint-<step>. A user-named
tmp-checkpoint-final / tmp-checkpoint-backup / tmp-checkpoint-notes
must NOT be deleted by the cancel cleanup."""
from core.training.training import _cleanup_cancelled_checkpoints
out = outputs_setup / "run-non-numeric"
out.mkdir()
numeric = _mk_dir(out, "tmp-checkpoint-100")
user_final = _mk_dir(out, "tmp-checkpoint-final")
user_backup = _mk_dir(out, "tmp-checkpoint-backup")
user_notes = _mk_dir(out, "tmp-checkpoint-user-notes")
_cleanup_cancelled_checkpoints(out)
assert not numeric.exists(), "in-flight tmp-checkpoint-100 should be removed"
assert user_final.exists(), "user dir tmp-checkpoint-final must be preserved"
assert user_backup.exists(), "user dir tmp-checkpoint-backup must be preserved"
assert user_notes.exists(), "user dir tmp-checkpoint-user-notes must be preserved"

View file

@ -0,0 +1,237 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Backend contract for the GGUF reload duplicate-load guard.
``LlamaCppBackend._already_in_target_state`` is the in-process
short-circuit that prevents a serialised duplicate /load from killing
the just-spawned llama-server. These tests pin the local-file
identity, the HF-mode hf_variant fallback, and the ``extra_args``
None-vs-[] inherit semantics so the guard cannot silently regress.
"""
from __future__ import annotations
import sys
import types as _types
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)
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
_structlog_stub = _types.ModuleType("structlog")
_structlog_stub.get_logger = lambda *a, **k: __import__("logging").getLogger("stub")
sys.modules.setdefault("structlog", _structlog_stub)
_httpx_stub = _types.ModuleType("httpx")
for _exc in (
"ConnectError",
"TimeoutException",
"ReadTimeout",
"ReadError",
"RemoteProtocolError",
"CloseError",
):
setattr(_httpx_stub, _exc, type(_exc, (Exception,), {}))
_httpx_stub.Timeout = type("T", (), {"__init__": lambda s, *a, **k: None})
_httpx_stub.Client = type(
"C",
(),
{
"__init__": lambda s, **kw: None,
"__enter__": lambda s: s,
"__exit__": lambda s, *a: None,
},
)
sys.modules.setdefault("httpx", _httpx_stub)
from core.inference.llama_cpp import LlamaCppBackend
class _FakeProcess:
"""Stand-in for subprocess.Popen so atexit cleanup doesn't crash."""
def terminate(self):
pass
def wait(self, timeout = None):
return 0
def kill(self):
pass
def poll(self):
return 0
def _loaded_backend(**overrides):
backend = LlamaCppBackend()
backend._process = _FakeProcess() # is_loaded only checks "is not None"
backend._healthy = True
backend._model_identifier = "owner/repo"
backend._hf_variant = "Q4_K_M"
backend._requested_n_ctx = 8192
backend._cache_type_kv = None
backend._speculative_type = None
backend._chat_template_override = None
backend._is_vision = False
backend._extra_args = None
backend._extra_args_source = None
backend._gguf_path = None
for key, value in overrides.items():
setattr(backend, key, value)
return backend
# ── Local-file identity via gguf_path ────────────────────────────────
def test_already_in_target_state_uses_gguf_path_when_present(tmp_path):
gguf_file = tmp_path / "model.Q4_K_M.gguf"
gguf_file.write_bytes(b"")
backend = _loaded_backend(
_hf_variant = "Q4_K_M",
_gguf_path = str(gguf_file),
)
assert (
backend._already_in_target_state(
gguf_path = str(gguf_file),
model_identifier = "owner/repo",
hf_variant = None,
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = None,
is_vision = False,
)
is True
)
def test_already_in_target_state_rejects_different_gguf_path(tmp_path):
a = tmp_path / "a.gguf"
a.write_bytes(b"")
b = tmp_path / "b.gguf"
b.write_bytes(b"")
backend = _loaded_backend(_gguf_path = str(a))
assert (
backend._already_in_target_state(
gguf_path = str(b),
model_identifier = "owner/repo",
hf_variant = None,
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = None,
is_vision = False,
)
is False
)
# ── HF mode falls back to hf_variant comparison ──────────────────────
def test_already_in_target_state_falls_back_to_hf_variant_for_hf_loads():
backend = _loaded_backend(_hf_variant = "Q4_K_M", _gguf_path = None)
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "owner/repo",
hf_variant = "Q8_0",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = None,
is_vision = False,
)
is False
)
def test_already_in_target_state_hf_same_variant_matches():
backend = _loaded_backend(_hf_variant = "Q4_K_M", _gguf_path = None)
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "owner/repo",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = None,
is_vision = False,
)
is True
)
# ── extra_args: None inherits, [] forces reload, list enforces ───────
def test_already_in_target_state_none_extras_inherits_stored():
backend = _loaded_backend(_extra_args = ["--top-k", "20"])
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "owner/repo",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = None,
is_vision = False,
)
is True
)
def test_already_in_target_state_empty_extras_forces_reload_when_stored():
backend = _loaded_backend(_extra_args = ["--top-k", "20"])
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "owner/repo",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = [],
is_vision = False,
)
is False
)
def test_already_in_target_state_explicit_extras_match():
backend = _loaded_backend(_extra_args = ["--top-k", "20"])
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "owner/repo",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = ["--top-k", "20"],
is_vision = False,
)
is True
)
def test_extra_args_source_default_is_none():
backend = LlamaCppBackend()
assert backend.extra_args_source is None

View file

@ -34,3 +34,193 @@ def test_nonblank_chat_template_override_is_preserved_verbatim():
req = _base_load_request(chat_template_override = template)
assert req.chat_template_override == template
# ---------- ChatCompletionRequest tool_call_id walkback ----------
from models.inference import ChatCompletionRequest
def _req(messages, **overrides):
payload = {"model": "x", "messages": messages, **overrides}
return ChatCompletionRequest.model_validate(payload)
def test_tool_message_inherits_id_from_prior_assistant_tool_call():
req = _req(
[
{"role": "user", "content": "what is 2+2"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_real123",
"type": "function",
"function": {"name": "calc", "arguments": "{}"},
}
],
},
{"role": "tool", "name": "calc", "content": "4"}, # no tool_call_id
]
)
assert req.messages[-1].tool_call_id == "call_real123"
def test_tool_message_with_explicit_id_unchanged():
req = _req(
[
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_a",
"type": "function",
"function": {"name": "search", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "call_user_supplied", "content": "ok"},
]
)
assert req.messages[-1].tool_call_id == "call_user_supplied"
def test_walkback_prefers_function_name_match():
req = _req(
[
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_x",
"type": "function",
"function": {"name": "search", "arguments": "{}"},
},
{
"id": "call_y",
"type": "function",
"function": {"name": "calc", "arguments": "{}"},
},
],
},
{"role": "tool", "name": "calc", "content": "4"},
]
)
assert req.messages[-1].tool_call_id == "call_y"
def test_walkback_takes_first_unconsumed_when_no_name():
req = _req(
[
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_a",
"type": "function",
"function": {"name": "calc", "arguments": "{}"},
},
{
"id": "call_b",
"type": "function",
"function": {"name": "search", "arguments": "{}"},
},
],
},
{"role": "tool", "content": "first result"},
{"role": "tool", "content": "second result"},
]
)
assert req.messages[-2].tool_call_id == "call_a"
assert req.messages[-1].tool_call_id == "call_b"
def test_walkback_falls_back_to_synth_when_no_assistant_turn():
req = _req(
[
{"role": "user", "content": "hi"},
{"role": "tool", "content": "orphan"},
]
)
tcid = req.messages[-1].tool_call_id
assert tcid is not None and tcid.startswith("call_") and len(tcid) > 5
def test_walkback_does_not_cross_user_turn():
req = _req(
[
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "old_call",
"type": "function",
"function": {"name": "calc", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "old_call", "content": "4"},
{"role": "user", "content": "next turn"},
{"role": "tool", "content": "no parent in this turn"},
]
)
last = req.messages[-1].tool_call_id
# The walkback must NOT pick old_call because a user turn intervenes;
# falls back to synth.
assert last is not None
assert last != "old_call"
assert last.startswith("call_")
def test_walkback_skips_explicitly_consumed_tool_call_id():
"""Sibling tool result with an explicit id must reserve its assistant
slot so a follow-up missing-id result picks the OTHER tool call."""
req = _req(
[
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_a",
"type": "function",
"function": {"name": "calc", "arguments": "{}"},
},
{
"id": "call_b",
"type": "function",
"function": {"name": "search", "arguments": "{}"},
},
],
},
{"role": "tool", "tool_call_id": "call_a", "content": "4"},
{"role": "tool", "content": "second result"},
]
)
assert [m.tool_call_id for m in req.messages if m.role == "tool"] == [
"call_a",
"call_b",
]
def test_walkback_handles_malformed_function_string():
"""A tool_call with ``function`` as a string (provider quirk) must not
raise; resolution falls back to fallback id selection."""
req = _req(
[
{
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "call_a", "type": "function", "function": "calc"},
],
},
{"role": "tool", "name": "calc", "content": "4"},
]
)
assert req.messages[-1].tool_call_id == "call_a"

View file

@ -0,0 +1,328 @@
# 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 llama.cpp prebuilt freshness check.
Pins the marker parser, the disk+memory cache, the stale decision
matrix, and fail-open behaviour on missing data.
"""
from __future__ import annotations
import json
import os
import sys
import time
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)
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
_structlog_stub = _types.ModuleType("structlog")
_structlog_stub.get_logger = lambda *a, **k: __import__("logging").getLogger("stub")
sys.modules.setdefault("structlog", _structlog_stub)
import pytest
from utils import llama_cpp_freshness as fr
# Helpers.
def _write_marker(install_dir: Path, **overrides) -> Path:
payload = {
"requested_tag": "latest",
"tag": "b9190",
"release_tag": "b9190",
"published_repo": "unslothai/llama.cpp",
"asset": "app-b9190-linux-x64-cuda13-newer.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)
(install_dir / "UNSLOTH_PREBUILT_INFO.json").write_text(json.dumps(payload))
return install_dir / "UNSLOTH_PREBUILT_INFO.json"
def _fake_binary(install_dir: Path, *, layout: str = "cmake") -> Path:
"""Stub llama-server under one of the supported install layouts."""
if layout == "cmake":
bin_dir = install_dir / "build" / "bin"
bin_name = "llama-server"
elif layout == "root":
bin_dir = install_dir
bin_name = "llama-server"
elif layout == "windows":
bin_dir = install_dir / "build" / "bin" / "Release"
bin_name = "llama-server.exe"
else:
raise ValueError(f"unknown layout {layout}")
bin_dir.mkdir(parents = True, exist_ok = True)
bin_path = bin_dir / bin_name
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 user's real cache.
monkeypatch.setattr(fr, "_cache_dir", lambda: tmp_path / ".freshness")
fr.reset_caches()
yield
fr.reset_caches()
# read_install_marker.
def test_read_install_marker_finds_cmake_layout(tmp_path):
install_dir = tmp_path / "llama.cpp"
_write_marker(install_dir, tag = "b9190")
bin_path = _fake_binary(install_dir, layout = "cmake")
marker = fr.read_install_marker(str(bin_path))
assert marker is not None
assert marker["tag"] == "b9190"
assert marker["published_repo"] == "unslothai/llama.cpp"
def test_read_install_marker_finds_root_layout(tmp_path):
install_dir = tmp_path / "llama.cpp"
_write_marker(install_dir, tag = "b9999")
bin_path = _fake_binary(install_dir, layout = "root")
marker = fr.read_install_marker(str(bin_path))
assert marker is not None
assert marker["tag"] == "b9999"
def test_read_install_marker_finds_windows_cmake_layout(tmp_path):
# Windows cmake puts the .exe under build/bin/Release/, so the
# marker is four levels above the binary.
install_dir = tmp_path / "llama.cpp"
_write_marker(install_dir, tag = "b8888")
bin_path = _fake_binary(install_dir, layout = "windows")
marker = fr.read_install_marker(str(bin_path))
assert marker is not None
assert marker["tag"] == "b8888"
@pytest.mark.parametrize("repo", ["unslothai/llama.cpp", "ggml-org/llama.cpp"])
def test_read_install_marker_carries_published_repo_dynamically(tmp_path, repo):
# The freshness check queries whichever release repo the marker
# records, so CUDA Linux (unslothai), CPU Linux x86_64 / macOS
# (ggml-org), and ROCm source-build (unslothai upstream label)
# all surface the right "latest" tag.
install_dir = tmp_path / "llama.cpp"
_write_marker(install_dir, tag = "b9000", published_repo = repo)
bin_path = _fake_binary(install_dir, layout = "cmake")
marker = fr.read_install_marker(str(bin_path))
assert marker is not None
assert marker["published_repo"] == repo
def test_read_install_marker_missing_returns_none(tmp_path):
bin_path = _fake_binary(tmp_path / "no_marker", layout = "root")
assert fr.read_install_marker(str(bin_path)) is None
def test_read_install_marker_handles_invalid_json(tmp_path):
install_dir = tmp_path / "llama.cpp"
install_dir.mkdir(parents = True)
(install_dir / "UNSLOTH_PREBUILT_INFO.json").write_text("not json")
bin_path = _fake_binary(install_dir, layout = "root")
assert fr.read_install_marker(str(bin_path)) is None
def test_read_install_marker_handles_none_path():
assert fr.read_install_marker(None) is None
# latest_published_release (with monkeypatched fetcher).
def test_latest_published_release_uses_disk_cache(monkeypatch):
calls = []
def _fake_fetch(repo, timeout = 5.0):
calls.append(repo)
return "b9999"
monkeypatch.setattr(fr, "_fetch_latest_release_tag", _fake_fetch)
first = fr.latest_published_release("unslothai/llama.cpp")
second = fr.latest_published_release("unslothai/llama.cpp")
assert first == "b9999"
assert second == "b9999"
# Memo + disk cache -> only one fetch.
assert len(calls) == 1
def test_latest_published_release_returns_none_on_network_failure(monkeypatch):
monkeypatch.setattr(fr, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: None)
assert fr.latest_published_release("unslothai/llama.cpp") is None
def test_latest_published_release_keeps_old_cache_on_transient_failure(
monkeypatch, tmp_path
):
# Disk entry older than TTL + network fail -> return cached value.
cache_dir = tmp_path / ".freshness"
cache_dir.mkdir()
cache_file = cache_dir / "unslothai__llama.cpp.json"
yesterday = time.time() - 25 * 60 * 60 # > 24h
cache_file.write_text(json.dumps({"fetched_at": yesterday, "latest_tag": "b9000"}))
monkeypatch.setattr(fr, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: None)
assert fr.latest_published_release("unslothai/llama.cpp") == "b9000"
# check_prebuilt_freshness end-to-end.
def test_check_prebuilt_freshness_reports_stale_when_old_and_behind(
monkeypatch, tmp_path
):
install_dir = tmp_path / "llama.cpp"
_write_marker(
install_dir,
tag = "b9190",
installed_at_utc = (datetime.now(tz = timezone.utc) - timedelta(days = 5))
.isoformat()
.replace("+00:00", "Z"),
)
bin_path = _fake_binary(install_dir, layout = "root")
monkeypatch.setattr(
fr, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9300"
)
info = fr.check_prebuilt_freshness(str(bin_path))
assert info["has_marker"] is True
assert info["stale"] is True
assert info["installed_tag"] == "b9190"
assert info["latest_tag"] == "b9300"
assert info["age_days"] == 5
assert info["published_repo"] == "unslothai/llama.cpp"
def test_check_prebuilt_freshness_not_stale_when_tag_matches(monkeypatch, tmp_path):
install_dir = tmp_path / "llama.cpp"
_write_marker(
install_dir,
tag = "b9300",
installed_at_utc = (datetime.now(tz = timezone.utc) - timedelta(days = 30))
.isoformat()
.replace("+00:00", "Z"),
)
bin_path = _fake_binary(install_dir, layout = "root")
monkeypatch.setattr(
fr, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9300"
)
info = fr.check_prebuilt_freshness(str(bin_path))
assert info["stale"] is False
assert info["installed_tag"] == "b9300"
assert info["latest_tag"] == "b9300"
def test_check_prebuilt_freshness_not_stale_within_threshold(monkeypatch, tmp_path):
# Behind by tag but within the 3-day grace window.
install_dir = tmp_path / "llama.cpp"
_write_marker(
install_dir,
tag = "b9190",
installed_at_utc = (datetime.now(tz = timezone.utc) - timedelta(days = 1))
.isoformat()
.replace("+00:00", "Z"),
)
bin_path = _fake_binary(install_dir, layout = "root")
monkeypatch.setattr(
fr, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9300"
)
info = fr.check_prebuilt_freshness(str(bin_path))
assert info["stale"] is False
assert info["age_days"] == 1
def test_check_prebuilt_freshness_fails_open_without_marker(tmp_path):
bin_path = _fake_binary(tmp_path / "custom_build", layout = "root")
info = fr.check_prebuilt_freshness(str(bin_path))
assert info["has_marker"] is False
assert info["stale"] is False
def test_check_prebuilt_freshness_fails_open_when_github_unreachable(
monkeypatch, tmp_path
):
install_dir = tmp_path / "llama.cpp"
_write_marker(
install_dir,
tag = "b9190",
installed_at_utc = (datetime.now(tz = timezone.utc) - timedelta(days = 10))
.isoformat()
.replace("+00:00", "Z"),
)
bin_path = _fake_binary(install_dir, layout = "root")
monkeypatch.setattr(fr, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: None)
info = fr.check_prebuilt_freshness(str(bin_path))
assert info["has_marker"] is True
assert info["stale"] is False
assert info["latest_tag"] is None
def test_check_prebuilt_freshness_handles_unparseable_install_timestamp(
monkeypatch, tmp_path
):
install_dir = tmp_path / "llama.cpp"
_write_marker(install_dir, tag = "b9190", installed_at_utc = "not-a-date")
bin_path = _fake_binary(install_dir, layout = "root")
monkeypatch.setattr(
fr, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9300"
)
info = fr.check_prebuilt_freshness(str(bin_path))
assert info["stale"] is False
assert info["age_days"] is None
def test_check_prebuilt_freshness_respects_custom_threshold(monkeypatch, tmp_path):
install_dir = tmp_path / "llama.cpp"
_write_marker(
install_dir,
tag = "b9190",
installed_at_utc = (datetime.now(tz = timezone.utc) - timedelta(days = 2))
.isoformat()
.replace("+00:00", "Z"),
)
bin_path = _fake_binary(install_dir, layout = "root")
monkeypatch.setattr(
fr, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9300"
)
info = fr.check_prebuilt_freshness(str(bin_path), threshold_days = 1)
assert info["stale"] is True
# format_stale_warning.
def test_format_stale_warning_contains_actionable_command():
msg = fr.format_stale_warning(
{"installed_tag": "b9190", "latest_tag": "b9300", "age_days": 5}
)
assert "b9190" in msg
assert "b9300" in msg
assert "5 days" in msg
assert "unsloth studio update" in msg
def test_format_stale_warning_singular_day():
msg = fr.format_stale_warning(
{"installed_tag": "b9190", "latest_tag": "b9300", "age_days": 1}
)
assert "1 day" in msg
assert "1 days" not in msg

View file

@ -0,0 +1,557 @@
# 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 MTP auto-detection path (llama.cpp #22673).
Pins three contracts: name-based detector, user-override detector, and
the _already_in_target_state mirror that prevents needless reloads.
"""
from __future__ import annotations
import struct
import sys
import types as _types
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)
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
_structlog_stub = _types.ModuleType("structlog")
_structlog_stub.get_logger = lambda *a, **k: __import__("logging").getLogger("stub")
sys.modules.setdefault("structlog", _structlog_stub)
_httpx_stub = _types.ModuleType("httpx")
for _exc in (
"ConnectError",
"TimeoutException",
"ReadTimeout",
"ReadError",
"RemoteProtocolError",
"CloseError",
):
setattr(_httpx_stub, _exc, type(_exc, (Exception,), {}))
_httpx_stub.Timeout = type("T", (), {"__init__": lambda s, *a, **k: None})
_httpx_stub.Client = type(
"C",
(),
{
"__init__": lambda s, **kw: None,
"__enter__": lambda s: s,
"__exit__": lambda s, *a: None,
},
)
sys.modules.setdefault("httpx", _httpx_stub)
import pytest
from core.inference.llama_cpp import (
LlamaCppBackend,
_extra_args_set_spec_type,
_is_mtp_model_name,
)
# Synthetic GGUF helper (mirrors test_gguf_metadata.py).
_GGUF_MAGIC = 0x46554747
_VTYPE_STRING = 8
_VTYPE_UINT32 = 4
def _enc_string(s: str) -> bytes:
b = s.encode("utf-8")
return struct.pack("<Q", len(b)) + b
def _enc_kv_string(key: str, value: str) -> bytes:
return _enc_string(key) + struct.pack("<I", _VTYPE_STRING) + _enc_string(value)
def _enc_kv_uint32(key: str, value: int) -> bytes:
return (
_enc_string(key) + struct.pack("<I", _VTYPE_UINT32) + struct.pack("<I", value)
)
def _write_minimal_gguf(
path: Path,
*,
arch: str,
nextn: int | None,
extra_uint32: dict[str, int] | None = None,
) -> Path:
"""Header-only GGUF with arch + optional nextn_predict_layers."""
extra_uint32 = dict(extra_uint32 or {})
body = _enc_kv_string("general.architecture", arch)
kv_count = 1
if nextn is not None:
body += _enc_kv_uint32(f"{arch}.nextn_predict_layers", nextn)
kv_count += 1
for k, v in extra_uint32.items():
body += _enc_kv_uint32(k, v)
kv_count += 1
header = struct.pack("<IIQQ", _GGUF_MAGIC, 3, 0, kv_count)
path.write_bytes(header + body)
return path
# _is_mtp_model_name helper.
@pytest.mark.parametrize(
"identifier",
[
"unsloth/Qwen3.6-27B-MTP-GGUF",
"unsloth/Qwen3.6-35B-A3B-MTP-GGUF",
"unsloth/qwen3.6-27b-mtp-gguf",
"unsloth/Qwen3.6-27B-Mtp-GGUF",
"unsloth/Qwen3.6-27B-MTP-GGUF:UD-Q4_K_XL",
],
)
def test_is_mtp_model_name_detects_marker_in_identifier(identifier):
assert _is_mtp_model_name(identifier) is True
@pytest.mark.parametrize(
"identifier",
[
"unsloth/Qwen3-27B-GGUF",
"unsloth/Llama-3.1-8B-Instruct-GGUF",
"google/gemma-3-4b-it",
# mtp inside an org name should not match.
"mtp-research/foo",
"MTPower/bar",
],
)
def test_is_mtp_model_name_does_not_overmatch(identifier):
assert _is_mtp_model_name(identifier) is False
def test_is_mtp_model_name_handles_none():
assert _is_mtp_model_name(None) is False
assert _is_mtp_model_name(None, None) is False
assert _is_mtp_model_name("", "") is False
def test_is_mtp_model_name_detects_marker_in_filename(tmp_path):
gguf = tmp_path / "Qwen3.6-27B-MTP-Q4_K_M.gguf"
gguf.write_bytes(b"")
assert _is_mtp_model_name("local-model", str(gguf)) is True
def test_is_mtp_model_name_filename_case_insensitive(tmp_path):
gguf = tmp_path / "qwen3.6-35b-a3b-mtp-q4_k_m.gguf"
gguf.write_bytes(b"")
assert _is_mtp_model_name(None, str(gguf)) is True
def test_is_mtp_model_name_ignores_non_mtp_filename(tmp_path):
gguf = tmp_path / "Qwen3.6-27B-Q4_K_M.gguf"
gguf.write_bytes(b"")
assert _is_mtp_model_name("local-model", str(gguf)) is False
# _already_in_target_state MTP promotion.
class _FakeProcess:
"""Minimal stand-in so is_loaded returns True."""
def terminate(self):
pass
def wait(self, timeout = None):
return 0
def kill(self):
pass
def poll(self):
return 0
def _mtp_backend(**overrides):
"""MTP-named GGUF backend that's already running with draft-mtp."""
backend = LlamaCppBackend()
backend._process = _FakeProcess()
backend._healthy = True
backend._model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF"
backend._hf_variant = "Q4_K_M"
backend._requested_n_ctx = 8192
backend._cache_type_kv = None
backend._speculative_type = "draft-mtp"
backend._chat_template_override = None
backend._is_vision = False
backend._extra_args = None
backend._extra_args_source = None
backend._gguf_path = None
for key, value in overrides.items():
setattr(backend, key, value)
return backend
def test_already_in_target_state_matches_when_request_omits_spec_for_mtp_model():
# Duplicate /load with no spec must match a running draft-mtp backend.
backend = _mtp_backend()
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = None,
is_vision = False,
)
is True
)
def test_already_in_target_state_matches_when_request_uses_default_for_mtp_model():
backend = _mtp_backend()
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = "default",
chat_template_override = None,
extra_args = None,
is_vision = False,
)
is True
)
def test_already_in_target_state_non_mtp_model_unaffected():
# Promotion is gated on the name; non-MTP must still mismatch req=None.
backend = _mtp_backend(_model_identifier = "unsloth/Qwen3.6-27B-GGUF")
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "unsloth/Qwen3.6-27B-GGUF",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = None,
is_vision = False,
)
is False
)
def test_already_in_target_state_explicit_off_still_mismatches_mtp_backend():
backend = _mtp_backend()
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = "off",
chat_template_override = None,
extra_args = None,
is_vision = False,
)
is False
)
# User override via extra_args (unsloth run / unsloth studio run).
@pytest.mark.parametrize(
"extra_args",
[
["--spec-type", "none"],
["--spec-type", "ngram-mod"],
["--spec-type", "draft-mtp"],
["--spec-type=none"],
["--top-k", "20", "--spec-type", "ngram-simple", "--seed", "42"],
["--spec-default"],
],
)
def test_extra_args_set_spec_type_detects_user_override(extra_args):
assert _extra_args_set_spec_type(extra_args) is True
@pytest.mark.parametrize(
"extra_args",
[
None,
[],
# Scalar tuning knobs compose safely with auto-emitted --spec-type.
["--spec-draft-n-max", "4"],
["--spec-ngram-mod-n-match", "32"],
["--draft-max", "32"],
["--top-k", "20", "--seed", "42"],
],
)
def test_extra_args_set_spec_type_passes_on_non_spec_type_args(extra_args):
assert _extra_args_set_spec_type(extra_args) is False
def test_already_in_target_state_user_spec_type_override_matches_clean_backend():
# User --spec-type none suppressed auto-MTP; repeat /load must not re-promote.
backend = _mtp_backend(
_speculative_type = None,
_extra_args = ["--spec-type", "none"],
)
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = ["--spec-type", "none"],
is_vision = False,
)
is True
)
def test_already_in_target_state_local_file_mtp_match(tmp_path):
# Local-file load: -MTP marker comes from the filename.
gguf = tmp_path / "Qwen3.6-35B-A3B-MTP-Q4_K_M.gguf"
gguf.write_bytes(b"")
backend = _mtp_backend(
_model_identifier = "local-qwen-mtp",
_gguf_path = str(gguf),
_hf_variant = None,
)
assert (
backend._already_in_target_state(
gguf_path = str(gguf),
model_identifier = "local-qwen-mtp",
hf_variant = None,
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = None,
is_vision = False,
)
is True
)
def test_already_in_target_state_vision_mtp_match():
# llama.cpp #22673: MTP is compatible with mmproj. A vision MTP load
# with auto/default spec must match a backend already running draft-mtp.
backend = _mtp_backend(_is_vision = True)
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = None,
is_vision = True,
)
is True
)
def test_already_in_target_state_vision_mtp_default_matches():
backend = _mtp_backend(_is_vision = True)
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = "default",
chat_template_override = None,
extra_args = None,
is_vision = True,
)
is True
)
def test_already_in_target_state_vision_non_mtp_unaffected():
# Vision non-MTP repo (no -MTP marker) must still mismatch req=None
# against a backend running draft-mtp.
backend = _mtp_backend(
_model_identifier = "unsloth/Qwen3-VL-4B-Instruct-GGUF",
_is_vision = True,
)
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "unsloth/Qwen3-VL-4B-Instruct-GGUF",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = None,
is_vision = True,
)
is False
)
# GGUF-metadata-based detection (nextn_predict_layers).
@pytest.mark.parametrize(
"arch, nextn",
[
# Verified against real Unsloth MTP GGUFs (qwen35 / qwen35moe).
("qwen35", 1),
("qwen35moe", 1),
# Future-proofing: any arch + n>0 should match.
("qwen3moe", 2),
("hypothetical_future_arch", 4),
],
)
def test_read_gguf_metadata_captures_nextn_predict_layers(tmp_path, arch, nextn):
gguf = _write_minimal_gguf(
tmp_path / "model.gguf",
arch = arch,
nextn = nextn,
extra_uint32 = {f"{arch}.block_count": 4},
)
backend = LlamaCppBackend()
backend._read_gguf_metadata(str(gguf))
assert backend._nextn_predict_layers == nextn
def test_read_gguf_metadata_leaves_nextn_unset_for_non_mtp_arch(tmp_path):
gguf = _write_minimal_gguf(
tmp_path / "model.gguf",
arch = "qwen3",
nextn = None,
extra_uint32 = {"qwen3.block_count": 4},
)
backend = LlamaCppBackend()
backend._read_gguf_metadata(str(gguf))
assert backend._nextn_predict_layers is None
def test_read_gguf_metadata_zero_nextn_is_falsy(tmp_path):
# bool(0) is False, so the spec block short-circuits.
gguf = _write_minimal_gguf(
tmp_path / "model.gguf",
arch = "qwen35",
nextn = 0,
extra_uint32 = {"qwen35.block_count": 4},
)
backend = LlamaCppBackend()
backend._read_gguf_metadata(str(gguf))
assert backend._nextn_predict_layers == 0
assert bool(backend._nextn_predict_layers) is False
def test_unload_resets_nextn_predict_layers():
# MTP state from a previous load must not bleed into the next load.
backend = LlamaCppBackend()
backend._nextn_predict_layers = 1
backend.unload_model()
assert backend._nextn_predict_layers is None
# llama-server capability probe.
def _make_fake_llama_server(path: Path, help_text: str) -> Path:
"""Bash stub that prints `help_text` on --help."""
path.write_text("#!/usr/bin/env bash\n" f"cat <<'EOF'\n{help_text}\nEOF\n")
path.chmod(0o755)
return path
def _clear_caps_cache():
LlamaCppBackend._capability_cache.clear()
def test_probe_server_capabilities_detects_draft_mtp(tmp_path):
# Original naming from llama.cpp #22673.
fake = _make_fake_llama_server(
tmp_path / "llama-server",
"--spec-type none,draft-simple,draft-eagle3,draft-mtp,"
"ngram-simple,ngram-map-k,ngram-map-k4v,ngram-mod,ngram-cache",
)
_clear_caps_cache()
caps = LlamaCppBackend.probe_server_capabilities(str(fake))
assert caps["found"] is True
assert caps["mtp_token"] == "draft-mtp"
assert caps["supports_mtp"] is True
def test_probe_server_capabilities_detects_renamed_mtp(tmp_path):
# Renamed upstream: draft-mtp -> mtp.
fake = _make_fake_llama_server(
tmp_path / "llama-server",
"--spec-type [none|mtp|ngram-cache|ngram-simple|ngram-map-k|"
"ngram-map-k4v|ngram-mod]",
)
_clear_caps_cache()
caps = LlamaCppBackend.probe_server_capabilities(str(fake))
assert caps["mtp_token"] == "mtp"
assert caps["supports_mtp"] is True
def test_probe_server_capabilities_reports_outdated_binary(tmp_path):
# Pre-MTP llama.cpp: only ngram variants.
fake = _make_fake_llama_server(
tmp_path / "llama-server",
"--spec-type none,ngram-simple,ngram-mod",
)
_clear_caps_cache()
caps = LlamaCppBackend.probe_server_capabilities(str(fake))
assert caps["found"] is True
assert caps["mtp_token"] is None
assert caps["supports_mtp"] is False
def test_probe_server_capabilities_handles_missing_binary():
_clear_caps_cache()
caps = LlamaCppBackend.probe_server_capabilities("/no/such/llama-server")
assert caps["found"] is False
assert caps["supports_mtp"] is False
def test_probe_server_capabilities_caches_by_mtime(tmp_path):
# Same (path, mtime) -> cache hit. Bumped mtime -> re-probe.
fake = _make_fake_llama_server(
tmp_path / "llama-server",
"--spec-type none,ngram-mod",
)
_clear_caps_cache()
caps1 = LlamaCppBackend.probe_server_capabilities(str(fake))
assert caps1["supports_mtp"] is False
import os
import time
_make_fake_llama_server(
fake,
"--spec-type none,draft-mtp,ngram-mod",
)
new_mtime = int(time.time()) + 2
os.utime(fake, (new_mtime, new_mtime))
caps2 = LlamaCppBackend.probe_server_capabilities(str(fake))
assert caps2["mtp_token"] == "draft-mtp"
assert caps2["supports_mtp"] is True

View file

@ -0,0 +1,156 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for LlamaCppBackend._wait_for_health resilience.
The probe loop must swallow transient httpx errors and fall through to
the subprocess.poll() branch so a crashed llama-server surfaces a
structured "exited with code X" log instead of bubbling an opaque
exception up to the /api/inference/load route.
"""
from __future__ import annotations
import sys
import types as _types
from pathlib import Path
from unittest import mock
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# Match the stubbing pattern in sibling tests so the module imports in
# a lightweight env without fastapi.
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
sys.modules.setdefault("structlog", _types.ModuleType("structlog"))
import httpx # noqa: E402
from core.inference.llama_cpp import LlamaCppBackend # noqa: E402
# Sibling tests in this directory install lightweight httpx stubs via
# sys.modules.setdefault. When collected together, our `httpx` symbol
# may be one of those stubs, which lacks `get`. Ensure the production
# code finds a working `httpx.get` and the standard exception types
# regardless of collection order by adding the missing attributes.
if not hasattr(httpx, "get"):
httpx.get = None # placeholder; every test below monkeypatches it
for _exc_name in (
"ConnectError",
"TimeoutException",
"ReadError",
"RemoteProtocolError",
"WriteError",
):
if not hasattr(httpx, _exc_name):
setattr(httpx, _exc_name, type(_exc_name, (Exception,), {}))
def _make_backend(port: int = 12345) -> LlamaCppBackend:
"""Build a barebones LlamaCppBackend instance with only the
attributes _wait_for_health touches. Bypasses __init__ so we do not
pull in the full subprocess + logging stack."""
b = LlamaCppBackend.__new__(LlamaCppBackend)
b._port = port
b._stdout_thread = None
b._stdout_lines = []
b._process = mock.Mock()
return b
class TestWaitForHealthResilience:
def test_returns_true_on_first_200(self, monkeypatch):
b = _make_backend()
b._process.poll.return_value = None
ok_resp = mock.Mock(status_code = 200)
monkeypatch.setattr(httpx, "get", lambda *a, **kw: ok_resp)
assert b._wait_for_health(timeout = 1.0, interval = 0.01) is True
def test_read_error_loops_to_subprocess_poll(self, monkeypatch):
"""WinError 10054 maps to httpx.ReadError. The loop must swallow
it and the next iteration must detect the dead subprocess via
poll() != None, returning False with a structured exit-code log
instead of bubbling the ReadError."""
b = _make_backend()
# First iteration: process alive (so we reach the httpx probe).
# Second iteration: process has exited (so we hit the structured
# exit-code branch and return False).
b._process.poll.side_effect = [None, 1]
b._process.returncode = 1
b._stdout_lines = ["llama-server: ggml-cuda.dll failed to load"]
def raise_read_error(*a, **kw):
raise httpx.ReadError("WinError 10054")
monkeypatch.setattr(httpx, "get", raise_read_error)
assert b._wait_for_health(timeout = 5.0, interval = 0.01) is False
# Both iterations of the loop ran -- the ReadError did not bubble.
assert b._process.poll.call_count >= 2
def test_remote_protocol_error_also_swallowed(self, monkeypatch):
"""Partial / malformed response on the probe (server crashed
mid-headers) raises RemoteProtocolError -- also non-fatal."""
b = _make_backend()
b._process.poll.side_effect = [None, -1]
b._process.returncode = -1
def raise_rpe(*a, **kw):
raise httpx.RemoteProtocolError("partial response")
monkeypatch.setattr(httpx, "get", raise_rpe)
assert b._wait_for_health(timeout = 5.0, interval = 0.01) is False
assert b._process.poll.call_count >= 2
def test_write_error_also_swallowed(self, monkeypatch):
"""Send-side socket failure mid-request raises WriteError --
same recovery path as ReadError."""
b = _make_backend()
b._process.poll.side_effect = [None, 1]
b._process.returncode = 1
def raise_we(*a, **kw):
raise httpx.WriteError("connection broken on write")
monkeypatch.setattr(httpx, "get", raise_we)
assert b._wait_for_health(timeout = 5.0, interval = 0.01) is False
assert b._process.poll.call_count >= 2
def test_connect_error_swallowed_until_success(self, monkeypatch):
"""Sanity: existing ConnectError swallowing still works -- the
loop retries until llama-server eventually answers 200."""
b = _make_backend()
b._process.poll.return_value = None
calls = {"n": 0}
ok_resp = mock.Mock(status_code = 200)
def cycling(*a, **kw):
calls["n"] += 1
if calls["n"] < 3:
raise httpx.ConnectError("not yet")
return ok_resp
monkeypatch.setattr(httpx, "get", cycling)
assert b._wait_for_health(timeout = 5.0, interval = 0.01) is True
assert calls["n"] >= 3
def test_dead_process_before_probe_returns_false(self, monkeypatch):
"""If poll() != None on entry, _wait_for_health must return
False immediately without calling httpx at all."""
b = _make_backend()
b._process.poll.return_value = 137
b._process.returncode = 137
b._stdout_lines = ["llama-server: out of memory"]
called = {"n": 0}
def should_not_be_called(*a, **kw):
called["n"] += 1
raise AssertionError("httpx.get must not run when subprocess is dead")
monkeypatch.setattr(httpx, "get", should_not_be_called)
assert b._wait_for_health(timeout = 5.0, interval = 0.01) is False
assert called["n"] == 0

View file

@ -15,6 +15,7 @@ import pytest
from core.inference.llama_server_args import (
is_managed_flag,
strip_shadowing_flags,
validate_extra_args,
)
@ -41,6 +42,23 @@ from core.inference.llama_server_args import (
["--chat-template-kwargs", '{"reasoning_effort":"high"}'],
["--spec-type", "ngram-mod"],
["--spec-default"],
# MTP path (llama.cpp #22673).
["--spec-type", "draft-mtp"],
["--spec-type", "draft-mtp", "--spec-draft-n-max", "6"],
[
"--spec-type",
"draft-mtp",
"--spec-draft-n-max",
"3",
"--spec-type",
"ngram-mod",
"--spec-ngram-mod-n-match",
"24",
"--spec-ngram-mod-n-min",
"48",
"--spec-ngram-mod-n-max",
"6",
],
# Reasoning controls
["--reasoning-format", "deepseek"],
["-rea", "auto"],
@ -187,3 +205,149 @@ def test_is_managed_flag_false_for_pass_through():
assert is_managed_flag("--flash-attn") is False
assert is_managed_flag("-ngl") is False
assert is_managed_flag("--threads") is False
# ── strip_shadowing_flags ─────────────────────────────────────────────
def test_strip_shadowing_flags_drops_context_when_requested():
out = strip_shadowing_flags(
["-c", "4096", "--top-k", "20"],
strip_context = True,
strip_cache = False,
strip_spec = False,
strip_template = False,
)
assert out == ["--top-k", "20"]
def test_strip_shadowing_flags_keeps_context_when_not_requested():
out = strip_shadowing_flags(
["-c", "4096", "--top-k", "20"],
strip_context = False,
strip_cache = False,
strip_spec = False,
strip_template = False,
)
assert out == ["-c", "4096", "--top-k", "20"]
def test_strip_shadowing_flags_keeps_chat_template_when_template_disabled():
# Caller did not supply chat_template_override; the inherited
# --chat-template-file must survive the strip.
out = strip_shadowing_flags(
["--chat-template-file", "/tmp/custom.jinja", "--top-k", "20"],
strip_context = True,
strip_cache = True,
strip_spec = True,
strip_template = False,
)
assert out == ["--chat-template-file", "/tmp/custom.jinja", "--top-k", "20"]
def test_strip_shadowing_flags_drops_template_when_requested():
out = strip_shadowing_flags(
["--chat-template-file", "/tmp/custom.jinja", "--top-k", "20"],
strip_template = True,
)
assert out == ["--top-k", "20"]
def test_strip_shadowing_flags_keeps_cache_when_cache_disabled():
out = strip_shadowing_flags(
["--cache-type-k", "q8_0", "--cache-type-v", "q8_0", "--top-k", "20"],
strip_cache = False,
)
assert out == [
"--cache-type-k",
"q8_0",
"--cache-type-v",
"q8_0",
"--top-k",
"20",
]
def test_strip_shadowing_flags_keeps_spec_when_spec_disabled():
out = strip_shadowing_flags(
["--spec-type", "ngram-mod", "--draft-min", "48", "--top-k", "20"],
strip_spec = False,
)
assert out == [
"--spec-type",
"ngram-mod",
"--draft-min",
"48",
"--top-k",
"20",
]
def test_strip_shadowing_flags_drops_mtp_flags_when_requested():
# MTP / draft-mtp flags must be stripped when speculative_type is re-applied.
out = strip_shadowing_flags(
[
"--spec-type",
"draft-mtp",
"--spec-draft-n-max",
"6",
"--spec-ngram-mod-n-match",
"24",
"--spec-ngram-mod-n-min",
"48",
"--spec-ngram-mod-n-max",
"6",
"--top-k",
"20",
],
strip_spec = True,
)
assert out == ["--top-k", "20"]
def test_is_managed_flag_false_for_mtp_pass_through():
assert is_managed_flag("--spec-draft-n-max") is False
assert is_managed_flag("--spec-ngram-mod-n-match") is False
assert is_managed_flag("--spec-ngram-mod-n-min") is False
assert is_managed_flag("--spec-ngram-mod-n-max") is False
def test_strip_shadowing_flags_boolean_does_not_consume_next_token():
# --spec-default is a boolean shadowing flag; the value-skipping
# heuristic must skip just the flag, not the following positional.
out = strip_shadowing_flags(["--spec-default", "ngram-mod"], strip_spec = True)
assert out == ["ngram-mod"]
def test_strip_shadowing_flags_jinja_boolean_preserves_positional():
out = strip_shadowing_flags(["--jinja", "trailing-positional"], strip_template = True)
assert out == ["trailing-positional"]
def test_strip_shadowing_flags_no_jinja_boolean_preserves_positional():
out = strip_shadowing_flags(
["--no-jinja", "trailing-positional"], strip_template = True
)
assert out == ["trailing-positional"]
def test_strip_shadowing_flags_equals_form_drops_only_the_flag():
out = strip_shadowing_flags(["--ctx-size=4096", "--seed", "-1"], strip_context = True)
assert out == ["--seed", "-1"]
def test_strip_shadowing_flags_handles_none_input():
assert strip_shadowing_flags(None) == []
def test_strip_shadowing_flags_handles_empty_input():
assert strip_shadowing_flags([]) == []
def test_strip_shadowing_flags_defaults_strip_everything():
# The route's already-loaded comparator calls strip_shadowing_flags
# with no kwargs to detect ANY shadowing flag in stored extras.
out = strip_shadowing_flags(
["-c", "4096", "--cache-type-k", "q8_0", "--spec-default", "--jinja"]
)
assert out == []

View file

@ -0,0 +1,285 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Tests for the per-(ip, username) login rate limiter.
Covers:
- bucket key composition is (client-ip, username.lower())
- X-Forwarded-For is honoured only when UNSLOTH_STUDIO_TRUST_FORWARDED is set
- 429 detail body does NOT leak the client IP
- One username failing does not lock out a different user from the same IP
- One IP failing does not lock out the same user from a different IP
"""
import os
import sys
from pathlib import Path
import pytest
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
@pytest.fixture(autouse = True)
def _reset_buckets():
"""Clear the in-memory bucket dicts between tests."""
from routes import auth as auth_routes
auth_routes._LOGIN_BUCKETS.clear()
auth_routes._LOGIN_IP_BUCKETS.clear()
yield
auth_routes._LOGIN_BUCKETS.clear()
auth_routes._LOGIN_IP_BUCKETS.clear()
@pytest.fixture
def env_no_proxy(monkeypatch):
monkeypatch.delenv("UNSLOTH_STUDIO_TRUST_FORWARDED", raising = False)
@pytest.fixture
def env_trust_proxy(monkeypatch):
monkeypatch.setenv("UNSLOTH_STUDIO_TRUST_FORWARDED", "1")
class _FakeRequest:
def __init__(self, client_host = "127.0.0.1", headers = None):
from starlette.datastructures import Headers
self.client = type("Client", (), {"host": client_host})()
self.headers = Headers(headers or {})
# ---------- _client_ip ----------
class TestClientIp:
def test_uses_request_client_host_by_default(self, env_no_proxy):
from routes.auth import _client_ip
assert _client_ip(_FakeRequest("203.0.113.5")) == "203.0.113.5"
def test_ignores_xff_when_trust_off(self, env_no_proxy):
from routes.auth import _client_ip
req = _FakeRequest(
"127.0.0.1",
{"x-forwarded-for": "198.51.100.7, 10.0.0.1"},
)
# The proxy header could be spoofed; without the opt-in we
# only trust the direct connection.
assert _client_ip(req) == "127.0.0.1"
def test_honours_first_xff_when_trust_on(self, env_trust_proxy):
from routes.auth import _client_ip
req = _FakeRequest(
"127.0.0.1",
{"x-forwarded-for": "198.51.100.7, 10.0.0.1"},
)
assert _client_ip(req) == "198.51.100.7"
def test_falls_back_to_client_host_when_xff_missing(self, env_trust_proxy):
from routes.auth import _client_ip
assert _client_ip(_FakeRequest("203.0.113.9")) == "203.0.113.9"
def test_honours_forwarded_header_when_trust_on(self, env_trust_proxy):
from routes.auth import _client_ip
req = _FakeRequest(
"127.0.0.1",
{"forwarded": 'for="198.51.100.42";proto=https'},
)
assert _client_ip(req) == "198.51.100.42"
def test_unknown_when_no_client(self, env_no_proxy):
from routes.auth import _client_ip
req = _FakeRequest()
req.client = None
assert _client_ip(req) == "_unknown"
def test_xff_strips_ipv4_port(self, env_trust_proxy):
from routes.auth import _client_ip
req = _FakeRequest(
"127.0.0.1", {"x-forwarded-for": "198.51.100.7:50001, 10.0.0.1"}
)
assert _client_ip(req) == "198.51.100.7"
def test_xff_strips_bracketed_ipv6_port(self, env_trust_proxy):
from routes.auth import _client_ip
req = _FakeRequest(
"127.0.0.1", {"x-forwarded-for": "[2001:db8::1]:50001, 10.0.0.1"}
)
assert _client_ip(req) == "2001:db8::1"
def test_forwarded_strips_ipv4_port(self, env_trust_proxy):
from routes.auth import _client_ip
req = _FakeRequest(
"127.0.0.1", {"forwarded": 'for="198.51.100.7:50001";proto=https'}
)
assert _client_ip(req) == "198.51.100.7"
def test_forwarded_strips_bracketed_ipv6_port(self, env_trust_proxy):
from routes.auth import _client_ip
req = _FakeRequest(
"127.0.0.1", {"forwarded": 'for="[2001:db8::1]:50001";proto=https'}
)
assert _client_ip(req) == "2001:db8::1"
def test_forwarded_isolates_first_element(self, env_trust_proxy):
from routes.auth import _client_ip
# Multi-element Forwarded must pick the first element only,
# otherwise suffix variations create attacker-controlled buckets.
req = _FakeRequest(
"127.0.0.1",
{"forwarded": "for=198.51.100.42, for=10.0.0.1;proto=https"},
)
assert _client_ip(req) == "198.51.100.42"
def test_xff_invalid_ip_falls_back_to_client_host(self, env_trust_proxy):
from routes.auth import _client_ip
# A garbage XFF must not propagate into the bucket key.
req = _FakeRequest("127.0.0.1", {"x-forwarded-for": "not-an-ip"})
assert _client_ip(req) == "127.0.0.1"
# ---------- bucket compose / blocking ----------
class TestBucketKeyAndBlocking:
def test_record_per_user_isolates_other_users(self, env_no_proxy):
from routes.auth import (
_bucket_key,
_record_login_failure,
_login_blocked,
_LOGIN_MAX_FAILS,
)
req = _FakeRequest("203.0.113.1")
for _ in range(_LOGIN_MAX_FAILS):
_record_login_failure(_bucket_key(req, "alice"))
assert _login_blocked(_bucket_key(req, "alice")) > 0
# bob's account from the same IP is unaffected by alice's typos.
assert _login_blocked(_bucket_key(req, "bob")) == 0
def test_record_per_ip_isolates_other_ips(self, env_no_proxy):
from routes.auth import (
_bucket_key,
_record_login_failure,
_login_blocked,
_LOGIN_MAX_FAILS,
)
req_a = _FakeRequest("203.0.113.1")
req_b = _FakeRequest("203.0.113.2")
for _ in range(_LOGIN_MAX_FAILS):
_record_login_failure(_bucket_key(req_a, "alice"))
assert _login_blocked(_bucket_key(req_a, "alice")) > 0
# Same username, different IP, not blocked.
assert _login_blocked(_bucket_key(req_b, "alice")) == 0
def test_username_lowercased_in_key(self, env_no_proxy):
from routes.auth import _bucket_key
req = _FakeRequest("203.0.113.1")
assert _bucket_key(req, "Alice") == _bucket_key(req, "alice")
assert _bucket_key(req, "ALICE") == _bucket_key(req, "alice")
def test_rotating_usernames_hit_ip_aggregate_cap(self, env_no_proxy, monkeypatch):
"""Spraying nonexistent usernames from one IP must still be throttled."""
from routes import auth as auth_routes
monkeypatch.setattr(auth_routes, "_LOGIN_IP_MAX_FAILS", 5)
req = _FakeRequest("203.0.113.10")
for idx in range(5):
auth_routes._record_login_failure(auth_routes._unknown_user_key(req))
# Different "username" each attempt would not have throttled
# under per-(ip,username) only; the IP aggregate must.
# The next missing-user attempt is blocked.
assert auth_routes._login_blocked(auth_routes._unknown_user_key(req)) > 0
def test_unknown_user_bucket_is_single_sentinel(self, env_no_proxy):
"""Random unknown usernames from one IP collapse to one bucket."""
from routes import auth as auth_routes
req = _FakeRequest("203.0.113.11")
unknown_key = auth_routes._unknown_user_key(req)
for _ in range(20):
auth_routes._record_login_failure(unknown_key)
# Account bucket cardinality stays at exactly one sentinel entry
# for this IP regardless of how many distinct usernames sprayed.
ip_keys = [k for k in auth_routes._LOGIN_BUCKETS if k[0] == "203.0.113.11"]
assert len(ip_keys) == 1
assert ip_keys[0][1].startswith("\x00")
def test_account_bucket_cap_bounded(self, env_no_proxy, monkeypatch):
"""The per-account bucket dict cannot grow without bound."""
from routes import auth as auth_routes
monkeypatch.setattr(auth_routes, "_LOGIN_MAX_BUCKETS", 10)
req = _FakeRequest("203.0.113.12")
for idx in range(50):
auth_routes._record_login_failure((req.client.host, f"user-{idx}"))
# Hard cap respected; further keys do not allocate.
assert len(auth_routes._LOGIN_BUCKETS) <= 10
# ---------- /login 429 body ----------
class TestLogin429Body:
@pytest.fixture
def login_client(self, tmp_path, monkeypatch):
from auth import storage
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.auth import router as auth_router
import secrets as _secrets
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
monkeypatch.setattr(
storage, "_BOOTSTRAP_PW_PATH", tmp_path / ".bootstrap_password"
)
monkeypatch.setattr(storage, "_bootstrap_password", None)
storage.create_initial_user(
username = storage.DEFAULT_ADMIN_USERNAME,
password = "human-password-123",
jwt_secret = _secrets.token_urlsafe(64),
must_change_password = False,
)
app = FastAPI()
app.include_router(auth_router, prefix = "/api/auth")
return TestClient(app)
def test_429_detail_does_not_leak_ip(self, env_no_proxy, login_client):
from routes.auth import _LOGIN_MAX_FAILS
# Drive 6 failures from the same client IP / username.
for _ in range(_LOGIN_MAX_FAILS):
r = login_client.post(
"/api/auth/login",
json = {"username": "unsloth", "password": "wrong"},
)
assert r.status_code == 401
r = login_client.post(
"/api/auth/login",
json = {"username": "unsloth", "password": "wrong"},
)
assert r.status_code == 429
detail = r.json()["detail"]
# The 429 body must not interpolate the source IP.
assert "127.0.0.1" not in detail
assert "Too many" in detail
# Retry-After header is still set for clients.
assert "Retry-After" in r.headers

View file

@ -196,6 +196,28 @@ class TestSecurityHeadersMiddleware:
nonced = main_module._build_csp("XYZ")
assert "script-src 'self' 'nonce-XYZ';" in nonced
def test_img_src_allows_google_favicons(self, main_module):
# sources.tsx fetches https://www.google.com/s2/favicons?... ; without
# this allowlist entry citation favicons fall back to gray initials.
csp = main_module._build_csp()
img_directive = next(
chunk.strip()
for chunk in csp.split(";")
if chunk.strip().startswith("img-src ")
)
# Tokenise and compare with `==` so CodeQL's URL-substring rule does
# not read directive-string `in` membership as URL sanitisation.
img_sources = img_directive.split()
assert any(src == "https://www.google.com" for src in img_sources)
# Pre-existing favicon CDNs stay allowed.
for host in (
"https://t0.gstatic.com",
"https://t1.gstatic.com",
"https://t2.gstatic.com",
"https://t3.gstatic.com",
):
assert any(src == host for src in img_sources)
# =====================================================================
# /api/health auth gate
@ -228,17 +250,35 @@ def health_app(tmp_path, monkeypatch):
class TestHealthAuthGate:
def test_no_auth_returns_minimal_payload(self, health_app):
# Launcher / frontend bootstrap fields are available unauth so the Tauri
# watchdog can re-adopt a sibling backend and the SPA can detect chat-only
# mode before any token exists. Version / device_type still require a bearer.
LAUNCHER_BITS = (
"service",
"studio_root_id",
"chat_only",
"desktop_protocol_version",
"desktop_manageability_version",
"supports_desktop_auth",
"supports_desktop_backend_ownership",
"native_path_leases_supported",
)
FINGERPRINT_FIELDS = ("version", "studio_version", "device_type")
def test_no_auth_exposes_launcher_bits(self, health_app):
c = TestClient(health_app)
r = c.get("/api/health")
assert r.status_code == 200
body = r.json()
assert body["status"] == "healthy"
assert "timestamp" in body
for forbidden in ("version", "device_type", "studio_root_id"):
for field in self.LAUNCHER_BITS:
assert field in body, f"missing launcher bit: {field}"
assert body["service"] == "Unsloth UI Backend"
for forbidden in self.FINGERPRINT_FIELDS:
assert forbidden not in body
def test_invalid_bearer_returns_minimal_payload(self, health_app):
def test_invalid_bearer_returns_launcher_bits_only(self, health_app):
# Regression: calling the async dep without await made any Bearer header pass.
c = TestClient(health_app)
r = c.get(
@ -248,7 +288,9 @@ class TestHealthAuthGate:
assert r.status_code == 200
body = r.json()
assert body["status"] == "healthy"
for forbidden in ("version", "device_type", "studio_root_id"):
for field in self.LAUNCHER_BITS:
assert field in body
for forbidden in self.FINGERPRINT_FIELDS:
assert forbidden not in body
def test_valid_bearer_returns_full_payload(self, health_app):
@ -264,6 +306,5 @@ class TestHealthAuthGate:
assert r.status_code == 200
body = r.json()
assert body["status"] == "healthy"
assert "version" in body
assert "device_type" in body
assert "studio_root_id" in body
for field in self.LAUNCHER_BITS + self.FINGERPRINT_FIELDS:
assert field in body, f"missing: {field}"

View file

@ -0,0 +1,828 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Regression tests for the offline GGUF cache fallback path (#5505).
Three failure modes hit users when ``huggingface.co`` is unreachable
but the requested GGUF repo is fully cached locally:
* ``list_gguf_variants`` raised through ``HTTPException(500)`` so the
variant dropdown sat empty.
* ``detect_gguf_model_remote`` returned ``None`` so a GGUF-only repo
was misrouted into the transformers/Unsloth backend (on macOS this
surfaced as a hardware error).
* ``_download_gguf`` fell back to a synthetic ``{repo}-{variant}.gguf``
name that did not exist in cache when the in-repo filename did not
echo the repo name (e.g. ``unsloth/Qwen3.6-27B-MTP-GGUF`` ships
``Qwen3.6-27B-UD-Q4_K_XL.gguf`` with no ``MTP`` token).
Two follow-up regressions covered here:
* P1 #1: the cache-side variant filter must match the snapshot-relative
path, not just the basename, so subdir layouts like
``BF16/foo.gguf`` are findable.
* P1 #2: the DNS auto-detect must scope ``HF_HUB_OFFLINE`` to one load
via try/finally so a transient resolver hiccup cannot lock the
long-lived ``LlamaCppBackend`` singleton offline forever.
No GPU, no network, no subprocess. Linux, macOS, Windows compatible.
"""
from __future__ import annotations
import os
import socket
import sys
import types as _types
from pathlib import Path
from unittest.mock import patch
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# Stub heavy/unavailable external deps before importing the modules
# under test (same pattern as other studio backend tests).
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
_structlog_stub = _types.ModuleType("structlog")
sys.modules.setdefault("structlog", _structlog_stub)
# Prefer real httpx if installed (CI installs it). Stub only as fallback.
try:
import httpx # noqa: F401
except ImportError:
_httpx_stub = _types.ModuleType("httpx")
for _exc_name in (
"ConnectError",
"TimeoutException",
"ReadTimeout",
"ReadError",
"RemoteProtocolError",
"CloseError",
"HTTPError",
"RequestError",
"HTTPStatusError",
):
setattr(_httpx_stub, _exc_name, type(_exc_name, (Exception,), {}))
_httpx_stub.Response = type("Response", (), {})
_httpx_stub.Request = type("Request", (), {})
class _FakeTimeout:
def __init__(self, *a, **kw):
pass
_httpx_stub.Timeout = _FakeTimeout
_httpx_stub.Client = type(
"Client",
(),
{
"__init__": lambda self, **kw: None,
"__enter__": lambda self: self,
"__exit__": lambda self, *a: None,
},
)
sys.modules.setdefault("httpx", _httpx_stub)
from huggingface_hub import constants as hf_constants
from core.inference.llama_cpp import (
LlamaCppBackend,
_hf_offline_if_dns_dead,
_probe_dns_dead,
)
from utils.models.model_config import (
_detect_gguf_from_hf_cache,
_extract_quant_label,
_iter_hf_cache_snapshots,
_list_gguf_variants_from_hf_cache,
detect_gguf_model_remote,
list_gguf_variants,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _build_cache(
root: Path,
repo_id: str,
files: dict[str, int],
*,
snapshot_sha: str = "a" * 40,
) -> Path:
"""Create ``$root/models--<repo>/snapshots/<sha>/<rel>`` for each entry."""
repo_dir = root / f"models--{repo_id.replace('/', '--')}"
(repo_dir / "blobs").mkdir(parents = True, exist_ok = True)
snap = repo_dir / "snapshots" / snapshot_sha
snap.mkdir(parents = True, exist_ok = True)
for rel, size in files.items():
full = snap / rel
full.parent.mkdir(parents = True, exist_ok = True)
full.write_bytes(b"\0" * size)
return snap
@pytest.fixture
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))
return tmp_path
@pytest.fixture
def clean_offline_env(monkeypatch):
"""Strip ``HF_HUB_OFFLINE`` / ``TRANSFORMERS_OFFLINE`` for the test."""
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising = False)
def _siblings(items: dict[str, int]):
"""Mock ``hf_model_info(...).siblings`` payload."""
return _types.SimpleNamespace(
siblings = [
_types.SimpleNamespace(rfilename = name, size = size)
for name, size in items.items()
],
)
# ---------------------------------------------------------------------------
# _iter_hf_cache_snapshots
# ---------------------------------------------------------------------------
class TestIterHfCacheSnapshots:
def test_returns_empty_when_cache_dir_missing(self, monkeypatch):
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", "/no/such/dir")
assert list(_iter_hf_cache_snapshots("unsloth/foo")) == []
def test_returns_empty_when_repo_not_cached(self, hf_cache):
assert list(_iter_hf_cache_snapshots("unsloth/not-here")) == []
def test_returns_empty_when_snapshots_dir_missing(self, hf_cache):
# Repo dir exists but no snapshots/ inside.
(hf_cache / "models--unsloth--bare").mkdir()
assert list(_iter_hf_cache_snapshots("unsloth/bare")) == []
def test_yields_newest_first(self, hf_cache):
old = _build_cache(
hf_cache, "unsloth/multi", {"x.gguf": 1}, snapshot_sha = "a" * 40
)
new = _build_cache(
hf_cache, "unsloth/multi", {"y.gguf": 1}, snapshot_sha = "b" * 40
)
os.utime(old, (1000, 1000))
os.utime(new, (2000, 2000))
out = list(_iter_hf_cache_snapshots("unsloth/multi"))
assert [p.name for p in out] == ["b" * 40, "a" * 40]
def test_repo_id_match_is_case_insensitive(self, hf_cache):
_build_cache(hf_cache, "unsloth/Foo-GGUF", {"Foo-Q4_K_M.gguf": 1})
# Lookup with a different casing of the org/name still resolves
out = list(_iter_hf_cache_snapshots("UNSLOTH/foo-gguf"))
assert len(out) == 1
# ---------------------------------------------------------------------------
# _list_gguf_variants_from_hf_cache / list_gguf_variants
# ---------------------------------------------------------------------------
class TestListGgufVariantsFromCache:
def test_returns_variants_when_cached(self, hf_cache):
_build_cache(
hf_cache,
"unsloth/Qwen3.5-4B-GGUF",
{
"Qwen3.5-4B-UD-Q4_K_XL.gguf": 100,
"Qwen3.5-4B-Q2_K.gguf": 50,
},
)
out = _list_gguf_variants_from_hf_cache("unsloth/Qwen3.5-4B-GGUF")
assert out is not None
variants, has_vision = out
assert sorted(v.quant for v in variants) == ["Q2_K", "UD-Q4_K_XL"]
assert has_vision is False
def test_returns_none_when_not_cached(self, hf_cache):
assert _list_gguf_variants_from_hf_cache("unsloth/absent") is None
class TestListGgufVariantsOffline:
def test_offline_env_short_circuits_api(
self, hf_cache, clean_offline_env, monkeypatch
):
_build_cache(hf_cache, "unsloth/a", {"a-UD-Q4_K_XL.gguf": 1})
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
def boom(*a, **k):
raise AssertionError("API must not be called when offline env set")
with patch("huggingface_hub.model_info", boom):
variants, _has = list_gguf_variants("unsloth/a")
assert len(variants) == 1
assert variants[0].quant == "UD-Q4_K_XL"
def test_api_exception_falls_back_to_cache(
self,
hf_cache,
clean_offline_env,
):
_build_cache(hf_cache, "unsloth/a", {"a-Q4_K_M.gguf": 1})
def boom(*a, **k):
raise OSError("network down")
with patch("huggingface_hub.model_info", boom):
variants, _has = list_gguf_variants("unsloth/a")
assert len(variants) == 1
assert variants[0].quant == "Q4_K_M"
def test_api_exception_with_no_cache_reraises(self, hf_cache, clean_offline_env):
def boom(*a, **k):
raise OSError("network down")
with patch("huggingface_hub.model_info", boom):
with pytest.raises(OSError, match = "network down"):
list_gguf_variants("unsloth/never-cached")
def test_online_path_unaffected(self, hf_cache, clean_offline_env):
# When the API succeeds, cache is not consulted.
api_payload = _siblings({"a-UD-Q4_K_XL.gguf": 5, "a-Q2_K.gguf": 3})
def hf_info(*a, **k):
return api_payload
with patch("huggingface_hub.model_info", hf_info):
variants, _has = list_gguf_variants("unsloth/a")
assert sorted(v.quant for v in variants) == ["Q2_K", "UD-Q4_K_XL"]
# ---------------------------------------------------------------------------
# _detect_gguf_from_hf_cache / detect_gguf_model_remote
# ---------------------------------------------------------------------------
class TestDetectGgufFromCache:
def test_picks_best_quant(self, hf_cache):
_build_cache(
hf_cache,
"unsloth/a",
{"a-Q2_K.gguf": 1, "a-UD-Q4_K_XL.gguf": 1},
)
assert _detect_gguf_from_hf_cache("unsloth/a") == "a-UD-Q4_K_XL.gguf"
def test_subdir_only_quant_resolves(self, hf_cache):
"""P1 #1 regression: ``BF16/foo.gguf`` (quant only in directory).
Before the fix, the offline cache scan matched on basename and
missed this layout, falling through to the synthetic
``{repo}-{variant}.gguf`` heuristic."""
_build_cache(
hf_cache,
"unsloth/gpt-oss-20b-BF16",
{"BF16/foo.gguf": 1},
)
out = _detect_gguf_from_hf_cache("unsloth/gpt-oss-20b-BF16")
assert (
out == "BF16/foo.gguf"
), f"subdir-only layout must resolve to relative path, got {out}"
def test_returns_none_when_no_gguf(self, hf_cache):
_build_cache(hf_cache, "unsloth/a", {"README.md": 10})
assert _detect_gguf_from_hf_cache("unsloth/a") is None
class TestDetectGgufModelRemoteOffline:
def test_offline_env_short_circuits_retries(
self,
hf_cache,
clean_offline_env,
monkeypatch,
):
_build_cache(hf_cache, "unsloth/a", {"a-Q4_K_M.gguf": 1})
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
def boom(*a, **k):
raise AssertionError("API must not be called when offline env set")
with patch("huggingface_hub.model_info", boom):
assert detect_gguf_model_remote("unsloth/a") == "a-Q4_K_M.gguf"
def test_api_3x_failure_then_cache(self, hf_cache, clean_offline_env):
_build_cache(hf_cache, "unsloth/a", {"a-Q4_K_M.gguf": 1})
def boom(*a, **k):
raise OSError("hub down")
# Patch time.sleep so the 1s/2s/4s backoff doesn't slow the test.
with (
patch("huggingface_hub.model_info", boom),
patch("time.sleep", lambda *_: None),
):
out = detect_gguf_model_remote("unsloth/a")
assert out == "a-Q4_K_M.gguf"
def test_repository_not_found_does_not_consult_cache(
self,
hf_cache,
clean_offline_env,
):
# Cache has a file but the API explicitly says repo is gone.
_build_cache(hf_cache, "unsloth/a", {"a-Q4_K_M.gguf": 1})
class RepositoryNotFoundError(Exception):
pass
def gone(*a, **k):
raise RepositoryNotFoundError("404")
with patch("huggingface_hub.model_info", gone):
out = detect_gguf_model_remote("unsloth/a")
# Early-return semantics preserved: 404 wins over a stale cache.
assert out is None
# ---------------------------------------------------------------------------
# _probe_dns_dead / _hf_offline_if_dns_dead
# ---------------------------------------------------------------------------
class _DnsState:
"""Tiny helper that toggles ``socket.gethostbyname`` failure mode."""
def __init__(self, monkeypatch):
self._mp = monkeypatch
self._real = socket.gethostbyname
def fail(self):
def _fail(*a, **k):
raise socket.gaierror(-2, "Name or service not known")
self._mp.setattr(socket, "gethostbyname", _fail)
def ok(self):
self._mp.setattr(socket, "gethostbyname", lambda *a, **k: "127.0.0.1")
def restore(self):
self._mp.setattr(socket, "gethostbyname", self._real)
@pytest.fixture
def dns(monkeypatch):
return _DnsState(monkeypatch)
class TestProbeDnsDead:
def test_returns_false_on_success(self, dns):
dns.ok()
assert _probe_dns_dead() is False
def test_returns_true_on_failure(self, dns):
dns.fail()
assert _probe_dns_dead() is True
def test_restores_prior_socket_timeout(self, dns):
dns.ok()
socket.setdefaulttimeout(7.5)
try:
_probe_dns_dead()
assert socket.getdefaulttimeout() == 7.5
finally:
socket.setdefaulttimeout(None)
class TestHfOfflineIfDnsDead:
def test_dns_fail_sets_env_inside_block_only(self, dns, clean_offline_env):
dns.fail()
assert "HF_HUB_OFFLINE" not in os.environ
with _hf_offline_if_dns_dead() as did_set:
assert did_set is True
assert os.environ.get("HF_HUB_OFFLINE") == "1"
assert os.environ.get("TRANSFORMERS_OFFLINE") == "1"
# P1 #2: env must be restored after the block
assert "HF_HUB_OFFLINE" not in os.environ
assert "TRANSFORMERS_OFFLINE" not in os.environ
def test_dns_ok_is_noop(self, dns, clean_offline_env):
dns.ok()
with _hf_offline_if_dns_dead() as did_set:
assert did_set is False
assert "HF_HUB_OFFLINE" not in os.environ
def test_dns_recovers_between_calls(self, dns, clean_offline_env):
# First call: DNS dead -> env set inside, cleared on exit.
dns.fail()
with _hf_offline_if_dns_dead():
pass
assert "HF_HUB_OFFLINE" not in os.environ
# Second call: DNS healthy -> no env mutation.
dns.ok()
with _hf_offline_if_dns_dead() as did_set:
assert did_set is False
assert "HF_HUB_OFFLINE" not in os.environ
def test_user_set_hf_hub_offline_is_preserved(
self,
dns,
clean_offline_env,
monkeypatch,
):
# User explicitly set offline before launching Studio.
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
dns.fail()
with _hf_offline_if_dns_dead() as did_set:
assert did_set is False
assert os.environ.get("HF_HUB_OFFLINE") == "1"
# Helper must not pop a variable it did not set.
assert os.environ.get("HF_HUB_OFFLINE") == "1"
def test_user_set_transformers_offline_is_preserved(
self,
dns,
clean_offline_env,
monkeypatch,
):
monkeypatch.setenv("TRANSFORMERS_OFFLINE", "1")
dns.fail()
with _hf_offline_if_dns_dead():
assert os.environ.get("HF_HUB_OFFLINE") == "1"
assert os.environ.get("TRANSFORMERS_OFFLINE") == "1"
# HF_HUB_OFFLINE was set by helper -> removed.
assert "HF_HUB_OFFLINE" not in os.environ
# TRANSFORMERS_OFFLINE pre-existed -> preserved.
assert os.environ.get("TRANSFORMERS_OFFLINE") == "1"
def test_exception_inside_block_still_restores_env(
self,
dns,
clean_offline_env,
):
dns.fail()
with pytest.raises(RuntimeError, match = "boom"):
with _hf_offline_if_dns_dead():
raise RuntimeError("boom")
# Cleanup must happen on exception as well.
assert "HF_HUB_OFFLINE" not in os.environ
assert "TRANSFORMERS_OFFLINE" not in os.environ
class TestExtractQuantLabelSubdir:
"""``_extract_quant_label`` must consider the parent directories when
the basename has no quant token. Subdir layouts like ``BF16/foo.gguf``
are documented in this codebase and surface through the cache scan."""
def test_quant_in_basename_unchanged(self):
assert _extract_quant_label("BF16/foo-BF16.gguf") == "BF16"
assert _extract_quant_label("model-Q4_K_M.gguf") == "Q4_K_M"
def test_quant_only_in_parent_dir(self):
assert _extract_quant_label("BF16/foo.gguf") == "BF16"
def test_ud_prefix_in_parent_dir(self):
assert _extract_quant_label("UD-Q4_K_XL/weight.gguf") == "UD-Q4_K_XL"
def test_deeper_nesting_picks_nearest_quant_dir(self):
# When multiple parent segments could match, prefer the one closest
# to the file (innermost). This matches how repos like
# ``models/MXFP4_MOE/foo.gguf`` are laid out.
assert _extract_quant_label("models/MXFP4_MOE/foo.gguf") == "MXFP4_MOE"
class TestDownloadMmprojOfflineCacheFallback:
"""``LlamaCppBackend._download_mmproj`` must resolve cached mmproj
GGUFs offline, same shape as ``_download_gguf``. Without this the
offline vision GGUF load path returns ``None`` even when the mmproj
is present in cache."""
def test_cache_lookup_returns_cached_mmproj_when_list_repo_files_fails(
self,
hf_cache,
):
_build_cache(
hf_cache,
"unsloth/vision-GGUF",
{
"vision-Q4_K_M.gguf": 1,
"mmproj-vision-F16.gguf": 1,
},
)
backend = LlamaCppBackend()
def boom_list(*a, **k):
raise OSError("offline")
def fake_download(*, repo_id, filename, token = None):
# Echo back so the test can verify the cache-resolved filename
return f"/fake/cache/{repo_id}/{filename}"
with (
patch("huggingface_hub.list_repo_files", boom_list),
patch("huggingface_hub.hf_hub_download", fake_download),
):
out = backend._download_mmproj(
hf_repo = "unsloth/vision-GGUF",
hf_token = None,
)
assert out is not None, "mmproj must resolve from cache when offline"
assert "mmproj-vision-F16.gguf" in out
def test_prefers_f16_variant_when_multiple_mmproj_in_cache(self, hf_cache):
_build_cache(
hf_cache,
"unsloth/vision-GGUF",
{
"mmproj-vision-BF16.gguf": 1,
"mmproj-vision-F16.gguf": 1,
},
)
backend = LlamaCppBackend()
def boom_list(*a, **k):
raise OSError("offline")
captured = {}
def fake_download(*, repo_id, filename, token = None):
captured["filename"] = filename
return f"/fake/{filename}"
with (
patch("huggingface_hub.list_repo_files", boom_list),
patch("huggingface_hub.hf_hub_download", fake_download),
):
backend._download_mmproj(
hf_repo = "unsloth/vision-GGUF",
hf_token = None,
)
assert captured.get("filename") == "mmproj-vision-F16.gguf"
def test_no_mmproj_in_cache_returns_none(self, hf_cache):
_build_cache(
hf_cache,
"unsloth/text-only-GGUF",
{"text-Q4_K_M.gguf": 1},
)
backend = LlamaCppBackend()
def boom_list(*a, **k):
raise OSError("offline")
with patch("huggingface_hub.list_repo_files", boom_list):
out = backend._download_mmproj(
hf_repo = "unsloth/text-only-GGUF",
hf_token = None,
)
assert out is None
class TestListLocalGgufVariantsSubdir:
"""Subdir layouts like ``BF16/foo.gguf`` and ``Q4_K_M/foo.gguf`` must
produce distinct quant labels, not collapse on basename."""
def test_two_subdir_variants_do_not_collapse(self, tmp_path):
from utils.models.model_config import list_local_gguf_variants
(tmp_path / "config.json").write_text("{}")
(tmp_path / "BF16").mkdir()
(tmp_path / "BF16" / "foo.gguf").write_bytes(b"\0" * 100)
(tmp_path / "Q4_K_M").mkdir()
(tmp_path / "Q4_K_M" / "foo.gguf").write_bytes(b"\0" * 50)
variants, _ = list_local_gguf_variants(str(tmp_path))
quants = {v.quant for v in variants}
assert "BF16" in quants, f"BF16 missing from {quants}"
assert "Q4_K_M" in quants, f"Q4_K_M missing from {quants}"
assert len(variants) == 2
def test_find_local_gguf_by_variant_locates_subdir(self, tmp_path):
from utils.models.model_config import _find_local_gguf_by_variant
(tmp_path / "config.json").write_text("{}")
(tmp_path / "BF16").mkdir()
target = tmp_path / "BF16" / "foo.gguf"
target.write_bytes(b"\0" * 10)
out = _find_local_gguf_by_variant(str(tmp_path), "BF16")
assert out is not None
assert Path(out).name == "foo.gguf"
class TestListGgufVariantsPermanentErrors:
"""Permanent HF errors must surface; cache fallback only on transient."""
def test_repository_not_found_re_raises(self, hf_cache, clean_offline_env):
from utils.models.model_config import list_gguf_variants
_build_cache(hf_cache, "u/repo-gguf", {"foo-Q4_K_M.gguf": 1})
class _RepoNotFound(Exception):
pass
_RepoNotFound.__name__ = "RepositoryNotFoundError"
def boom(*a, **k):
raise _RepoNotFound("repo deleted")
with patch("huggingface_hub.model_info", boom):
with pytest.raises(Exception) as exc_info:
list_gguf_variants("u/repo-gguf")
assert type(exc_info.value).__name__ == "RepositoryNotFoundError"
def test_gated_repo_re_raises(self, hf_cache, clean_offline_env):
from utils.models.model_config import list_gguf_variants
_build_cache(hf_cache, "u/gated-gguf", {"foo-Q4_K_M.gguf": 1})
class _GatedRepo(Exception):
pass
_GatedRepo.__name__ = "GatedRepoError"
def boom(*a, **k):
raise _GatedRepo("auth required")
with patch("huggingface_hub.model_info", boom):
with pytest.raises(Exception) as exc_info:
list_gguf_variants("u/gated-gguf")
assert type(exc_info.value).__name__ == "GatedRepoError"
def test_transient_error_still_falls_back_to_cache(
self, hf_cache, clean_offline_env
):
from utils.models.model_config import list_gguf_variants
_build_cache(hf_cache, "u/transient-gguf", {"foo-Q4_K_M.gguf": 1})
def boom(*a, **k):
raise OSError("network down")
with patch("huggingface_hub.model_info", boom):
variants, _ = list_gguf_variants("u/transient-gguf")
assert any(v.quant == "Q4_K_M" for v in variants)
class TestDetectGgufFromCacheExcludesMmproj:
"""A partial cache with only a vision projector must not route the
projector as the main model."""
def test_mmproj_only_returns_none(self, hf_cache):
from utils.models.model_config import _detect_gguf_from_hf_cache
_build_cache(
hf_cache,
"u/vision-only-mmproj",
{"mmproj-vision-F16.gguf": 1},
)
assert _detect_gguf_from_hf_cache("u/vision-only-mmproj") is None
def test_main_plus_mmproj_returns_main(self, hf_cache):
from utils.models.model_config import _detect_gguf_from_hf_cache
_build_cache(
hf_cache,
"u/vision-full",
{
"model-Q4_K_M.gguf": 1,
"mmproj-vision-F16.gguf": 1,
},
)
out = _detect_gguf_from_hf_cache("u/vision-full")
assert out is not None
assert "mmproj" not in out.lower()
class TestProbeDnsDeadNoGlobalTimeoutMutation:
"""``_probe_dns_dead`` must not change ``socket.setdefaulttimeout``
process-wide -- concurrent sockets without explicit timeout would
inherit it for the probe window."""
def test_default_timeout_unchanged_when_dns_up(self, monkeypatch):
import socket as _socket
from core.inference.llama_cpp import _probe_dns_dead
prev = _socket.getdefaulttimeout()
set_calls = []
original_set = _socket.setdefaulttimeout
def tracking_set(value):
set_calls.append(value)
original_set(value)
monkeypatch.setattr(_socket, "setdefaulttimeout", tracking_set)
monkeypatch.setattr(_socket, "gethostbyname", lambda h: "127.0.0.1")
try:
_probe_dns_dead("example.invalid", timeout = 0.5)
finally:
# Restore exact state regardless of any test-side mutation.
original_set(prev)
assert set_calls == [], (
f"_probe_dns_dead mutated socket.setdefaulttimeout {set_calls}; "
"must isolate timeout to the probe thread"
)
def test_returns_dead_when_resolver_wedges(self, monkeypatch):
import socket as _socket
from core.inference.llama_cpp import _probe_dns_dead
# Simulate a wedged resolver: thread blocks forever.
def wedged(host):
import threading
threading.Event().wait()
monkeypatch.setattr(_socket, "gethostbyname", wedged)
assert _probe_dns_dead("example.invalid", timeout = 0.1) is True
class TestWaitForHealthRetriesOnReadError:
"""A TCP RST mid-read while llama-server is still binding the port
(Windows: WinError 10054) must not abort the health-poll loop --
that masks a legitimate 'still warming up' state as a fatal load."""
def test_read_error_then_success(self, monkeypatch):
import httpx
from core.inference.llama_cpp import LlamaCppBackend
backend = LlamaCppBackend()
backend._port = 65500
class _FakeProc:
returncode = None
def poll(self):
return None
def terminate(self):
pass
def kill(self):
pass
def wait(self, timeout = None):
return 0
backend._process = _FakeProc()
backend._stdout_thread = None
backend._stdout_lines = []
calls = {"n": 0}
def fake_get(url, timeout = None):
calls["n"] += 1
if calls["n"] == 1:
raise httpx.ReadError("WinError 10054")
if calls["n"] == 2:
raise httpx.RemoteProtocolError("short read")
if calls["n"] == 3:
raise httpx.WriteError("peer dropped")
class _OK:
status_code = 200
return _OK()
monkeypatch.setattr("core.inference.llama_cpp.httpx.get", fake_get)
assert backend._wait_for_health(timeout = 5.0, interval = 0.01) is True
assert calls["n"] == 4, (
f"_wait_for_health should retry past ReadError/RemoteProtocol/Write; "
f"saw {calls['n']} attempts"
)
def test_real_process_exit_still_short_circuits(self, monkeypatch):
from core.inference.llama_cpp import LlamaCppBackend
backend = LlamaCppBackend()
backend._port = 65501
class _DeadProc:
returncode = 137
def poll(self):
return 137
def terminate(self):
pass
def kill(self):
pass
def wait(self, timeout = None):
return 137
backend._process = _DeadProc()
backend._stdout_thread = None
backend._stdout_lines = ["fatal: out of memory"]
assert backend._wait_for_health(timeout = 5.0, interval = 0.01) is False

View file

@ -0,0 +1,236 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Parent-process offline regression tests (follow-up to #5505).
Pins the LoRA-detect, transformers_version urllib short-circuit, and
training-worker DNS probe so a dead DNS no longer burns 30-60s of
soft-failed timeouts before the worker subprocess spawns.
No GPU, no network, no subprocess. Cross-platform.
"""
from __future__ import annotations
import os
import sys
import types as _types
from pathlib import Path
from unittest.mock import patch
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
sys.modules.setdefault("structlog", _types.ModuleType("structlog"))
# Prefer real httpx if installed (CI installs it). Stub only as fallback.
try:
import httpx # noqa: F401
except ImportError:
_hx = _types.ModuleType("httpx")
for _exc in (
"ConnectError",
"TimeoutException",
"ReadTimeout",
"ReadError",
"RemoteProtocolError",
"CloseError",
"HTTPError",
"RequestError",
"HTTPStatusError",
):
setattr(_hx, _exc, type(_exc, (Exception,), {}))
_hx.Response = type("Response", (), {})
_hx.Request = type("Request", (), {})
class _FakeTimeout:
def __init__(self, *a, **k):
pass
_hx.Timeout = _FakeTimeout
_hx.Client = type(
"Client",
(),
{
"__init__": lambda s, **k: None,
"__enter__": lambda s: s,
"__exit__": lambda s, *a: None,
},
)
sys.modules.setdefault("httpx", _hx)
from utils.models.model_config import _env_offline
from utils.transformers_version import (
_check_config_needs_550,
_check_tokenizer_config_needs_v5,
_env_offline as _env_offline_tv,
)
@pytest.fixture
def clean_offline_env(monkeypatch):
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising = False)
class TestEnvOffline:
def test_unset_is_false(self, clean_offline_env):
assert _env_offline() is False
assert _env_offline_tv() is False
def test_hf_hub_offline_truthy_values(self, monkeypatch, clean_offline_env):
for val in ("1", "true", "yes", "TRUE", "Yes"):
monkeypatch.setenv("HF_HUB_OFFLINE", val)
assert _env_offline() is True
assert _env_offline_tv() is True
def test_transformers_offline_alone_triggers(self, monkeypatch, clean_offline_env):
monkeypatch.setenv("TRANSFORMERS_OFFLINE", "1")
assert _env_offline() is True
def test_falsy_values(self, monkeypatch, clean_offline_env):
for val in ("", "0", "false", "no"):
monkeypatch.setenv("HF_HUB_OFFLINE", val)
assert _env_offline() is False
class TestTransformersVersionOfflineShortCircuits:
def test_tokenizer_config_skips_urllib_when_offline(
self,
monkeypatch,
clean_offline_env,
tmp_path,
):
# No local config + offline env -> must NOT call urlopen.
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
unique = f"unsloth/never-cached-{tmp_path.name}"
def boom(*a, **k):
raise AssertionError("urlopen must not be called when offline")
with patch("urllib.request.urlopen", boom):
assert _check_tokenizer_config_needs_v5(unique) is False
def test_config_550_skips_urllib_when_offline(
self,
monkeypatch,
clean_offline_env,
tmp_path,
):
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
unique = f"unsloth/never-cached-{tmp_path.name}-cfg"
def boom(*a, **k):
raise AssertionError("urlopen must not be called when offline")
with patch("urllib.request.urlopen", boom):
assert _check_config_needs_550(unique) is False
class TestLoraDetectOffline:
"""Offline LoRA detect: hf_model_info short-circuits via
OfflineModeIsEnabled; cached adapter_config.json wins."""
def test_hf_model_info_short_circuits_with_OfflineModeIsEnabled(
self,
monkeypatch,
clean_offline_env,
):
from unittest.mock import MagicMock
from utils.models.model_config import ModelConfig
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
# Studio catches Exception broadly; pin that the call still happens
# (so cached LoRAs aren't missed) and returns fast via mock.
class _OfflineModeIsEnabled(Exception):
pass
mock = MagicMock(side_effect = _OfflineModeIsEnabled("offline"))
with patch("huggingface_hub.model_info", mock):
try:
ModelConfig.from_identifier(
model_id = "unsloth/Qwen3.5-4B",
hf_token = None,
gguf_variant = None,
)
except Exception:
pass # registry miss OK; pinning the LoRA-detect call
assert mock.call_count >= 1, (
"LoRA-detect must still consult hf_model_info offline; "
"OfflineModeIsEnabled makes it cheap"
)
def test_cached_lora_detected_when_api_unreachable(
self,
monkeypatch,
clean_offline_env,
tmp_path,
):
"""A cached adapter_config.json must still mark the repo as a
LoRA when the HF API is unreachable."""
from huggingface_hub import constants as hf_constants
from utils.models.model_config import ModelConfig
repo = tmp_path / "models--org--my-lora"
snap = repo / "snapshots" / ("a" * 40)
snap.mkdir(parents = True)
(snap / "adapter_config.json").write_text(
'{"base_model_name_or_path": "unsloth/Llama-3-8B"}'
)
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
def boom(*a, **k):
raise OSError("hub unreachable")
with patch("huggingface_hub.model_info", boom):
try:
cfg = ModelConfig.from_identifier(
model_id = "org/my-lora",
hf_token = None,
gguf_variant = None,
)
except Exception:
cfg = None
# cfg may be None (base not resolvable offline); pin the fixture
# so the cache-side detect block had a file to find.
assert (snap / "adapter_config.json").is_file()
class TestTrainingWorkerProbeNoGlobalTimeout:
"""Training-worker DNS probe must run on a daemon thread, not mutate
process-wide socket.setdefaulttimeout (mirrors llama_cpp.py)."""
def test_training_worker_source_uses_thread_probe(self):
"""Static-pin against regression to setdefaulttimeout."""
import re
from pathlib import Path
src = Path(_BACKEND_DIR, "core", "training", "worker.py").read_text()
m = re.search(
r'if\s+"HF_HUB_OFFLINE"\s+not\s+in\s+os\.environ\s*:.*?'
r"print\([^)]*HF_HUB_OFFLINE=1[^)]*\)",
src,
flags = re.DOTALL,
)
assert m is not None, "could not locate offline auto-detect block"
block = m.group(0)
assert ".setdefaulttimeout(" not in block, (
"training worker still calls socket.setdefaulttimeout; "
"concurrent sockets would inherit the probe timeout"
)
assert (
"threading" in block and "Thread" in block
), "training worker probe must run on a daemon thread"

View file

@ -389,3 +389,149 @@ def test_stale_container_emits_invalidated(monkeypatch):
events = _tool_events(lines)
invalidated = [e for e in events if e["type"] == "container_invalidated"]
assert len(invalidated) == 1
def test_expired_container_triggers_transparent_retry(monkeypatch):
"""When OpenAI 400s with 'Container is expired' on a request that
carried container_reference, the streamer retries once with the
container field stripped. The user never sees an error line only
container_invalidated, then the normal stream from the retry.
"""
calls: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content.decode("utf-8"))
calls.append(body)
# Find the shell tool entry to inspect environment.type.
shell_env_type = None
for tool in body.get("tools", []) or []:
if tool.get("type") == "shell":
shell_env_type = tool.get("environment", {}).get("type")
break
# First call carries container_reference -> 400 expired.
# Retry omits container -> normal SSE stream.
if shell_env_type == "container_reference":
return httpx.Response(
400,
content = json.dumps(
{
"error": {
"message": "Container is expired.",
"type": "invalid_request_error",
}
}
).encode("utf-8"),
headers = {"content-type": "application/json"},
)
# Successful retry: minimal SSE — a completed response with a
# fresh container_id so container_ready latches.
sse = _openai_sse(
[
{
"type": "response.completed",
"response": {"container_id": "cntr_fresh_111"},
},
]
)
return httpx.Response(
200,
content = sse,
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
return await _collect(
client._stream_openai_responses(
messages = [{"role": "user", "content": "hi"}],
model = "gpt-5.5",
temperature = 0.7,
top_p = 0.95,
max_tokens = 4096,
enable_thinking = None,
reasoning_effort = None,
enabled_tools = ["code_execution"],
openai_code_exec_container_id = "cntr_stale_999",
)
)
lines = _drive(run())
events = _tool_events(lines)
# Two outbound HTTP calls were made: the expired-container attempt
# then the retry without the container field.
assert len(calls) == 2
shell_types = []
for body in calls:
for tool in body.get("tools", []) or []:
if tool.get("type") == "shell":
shell_types.append(tool.get("environment", {}).get("type"))
assert shell_types == ["container_reference", "container_auto"]
# container_invalidated emitted (frontend will null its stored id).
assert any(e.get("type") == "container_invalidated" for e in events)
# container_ready emitted from the retry stream with the fresh id.
assert any(
e.get("type") == "container_ready" and e.get("container_id") == "cntr_fresh_111"
for e in events
)
# CRUCIALLY: no SSE error line surfaced to the chat — only completion.
error_lines = [
line
for line in lines
if line.startswith("data:") and '"error"' in line and '"_toolEvent"' not in line
]
assert error_lines == [], f"unexpected error line(s): {error_lines}"
def test_expired_container_retries_only_once(monkeypatch):
"""If the retry ALSO fails (any 4xx, expired or otherwise), the
error is surfaced normally no infinite retry loop.
"""
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
call_count["n"] += 1
return httpx.Response(
400,
content = json.dumps(
{
"error": {
"message": "Container is expired.",
"type": "invalid_request_error",
}
}
).encode("utf-8"),
headers = {"content-type": "application/json"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
return await _collect(
client._stream_openai_responses(
messages = [{"role": "user", "content": "hi"}],
model = "gpt-5.5",
temperature = 0.7,
top_p = 0.95,
max_tokens = 4096,
enable_thinking = None,
reasoning_effort = None,
enabled_tools = ["code_execution"],
openai_code_exec_container_id = "cntr_stale_999",
)
)
lines = _drive(run())
# Exactly two calls (first + one retry). Third would mean an
# infinite loop.
assert call_count["n"] == 2
# The second failure surfaces normally as an error SSE line.
error_lines = [
line for line in lines if '"error"' in line and "_toolEvent" not in line
]
assert len(error_lines) >= 1

View file

@ -125,21 +125,23 @@ class TestChatMessageToolRoles:
)
assert msg.content is None
def test_tool_role_missing_tool_call_id_synthesised(self):
# Frontend drops the id on second-round POST; validator synthesises one.
def test_tool_role_missing_tool_call_id_left_for_request_validator(self):
# Per-message: missing tool_call_id is now allowed at this layer.
# ChatCompletionRequest's walkback fills it in from the prior
# assistant tool_calls; see test_inference_model_validation.py for
# the resolution coverage.
msg = ChatMessage(role = "tool", content = '{"temperature": 72}')
assert msg.tool_call_id is not None
assert msg.tool_call_id.startswith("call_")
assert len(msg.tool_call_id) >= len("call_") + 8
assert msg.tool_call_id is None
assert msg.content == '{"temperature": 72}'
def test_tool_role_empty_tool_call_id_synthesised(self):
def test_tool_role_empty_tool_call_id_left_for_request_validator(self):
msg = ChatMessage(
role = "tool",
tool_call_id = "",
content = '{"temperature": 72}',
)
assert msg.tool_call_id is not None
assert msg.tool_call_id.startswith("call_")
# Empty-string is treated the same as missing by the walkback.
assert msg.tool_call_id in (None, "")
# ── Role-aware content requirements ────────────────────────────
@ -299,11 +301,57 @@ class TestChatCompletionRequestToolFields:
def test_stream_defaults_false_matching_openai_spec(self):
# OpenAI's /v1/chat/completions spec defaults `stream` to false.
# Studio previously defaulted to true, which broke naive curl
# clients that omit `stream` (they expect a JSON blob, got SSE).
# clients (and .NET / System.Text.Json SDKs per #5047) that omit
# `stream` -- they expect a JSON blob, got SSE.
# Pin the corrected default so it can't silently regress.
req = self._make()
assert req.stream is False
def test_post_without_stream_field_decodes_to_stream_false_over_http(
self, monkeypatch
):
# Wire-level guard for the same default: a POST body that omits
# `stream` entirely (the exact shape naive curl / .NET clients
# send) must deserialise into stream=False *and* the response
# must be `application/json`, never `text/event-stream`.
# Mounts the real `routes.inference.router` so this catches
# regressions in middleware/aliasing on the actual endpoint
# (e.g. someone adding a request layer that injects stream=True
# before pydantic builds the model). Backends are bypassed by
# routing through `provider_type` and stubbing the external
# provider proxy.
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
import routes.inference as inference_route
from auth.authentication import get_current_subject
captured = {}
async def _fake_proxy(payload, request):
captured["stream"] = payload.stream
return JSONResponse({"choices": [], "object": "chat.completion"})
monkeypatch.setattr(inference_route, "_proxy_to_external_provider", _fake_proxy)
app = FastAPI()
app.include_router(inference_route.router)
app.dependency_overrides[get_current_subject] = lambda: "test-user"
client = TestClient(app)
resp = client.post(
"/chat/completions",
json = {
"messages": [{"role": "user", "content": "hi"}],
"provider_type": "openai",
},
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("application/json")
assert "text/event-stream" not in resp.headers["content-type"]
assert captured["stream"] is False
def test_multiturn_tool_loop_messages(self):
req = ChatCompletionRequest(
messages = [

View file

@ -0,0 +1,125 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
Regression test for the /recommended-folders (and /browse-folders) 500
caused by an unreadable model directory, e.g. a stock root-owned
``ollama`` install at ``/usr/share/ollama/.ollama/models``.
Root cause: the folder-scan helpers in ``routes.models`` probed candidate
paths with a bare ``Path(p).is_dir()``. On Python <= 3.11 that returned
``False`` for an unreadable path; on Python >= 3.12 ``is_dir()`` propagates
``PermissionError`` (EACCES), so the endpoint 500-ed through the whole
middleware stack instead of just skipping the directory. The probes now go
through the module-level ``_safe_is_dir`` helper.
``routes.models`` pulls the full backend dependency tree (fastapi,
structlog, the models package, ...), so rather than stand up the app we
extract the real ``_safe_is_dir`` definition from the source file and
exercise that exact function in isolation. The test therefore stays
dependency-free while still running the shipped code.
Run:
python -m pytest studio/backend/tests/test_recommended_folders_permission.py -v
"""
import ast
import os
import sys
from pathlib import Path
import pytest
_backend_root = Path(__file__).resolve().parent.parent
_models_src = _backend_root / "routes" / "models.py"
def _load_safe_is_dir():
"""Return the real ``_safe_is_dir`` from routes/models.py without
importing the (heavily dependency-laden) module."""
tree = ast.parse(_models_src.read_text())
fn = next(
node
for node in tree.body
if isinstance(node, ast.FunctionDef) and node.name == "_safe_is_dir"
)
module = ast.Module(body = [fn], type_ignores = [])
ns: dict = {"Path": Path, "os": os}
exec(compile(module, f"<extracted {_models_src}>", "exec"), ns)
return ns["_safe_is_dir"]
safe_is_dir = _load_safe_is_dir()
# Permission bits are bypassed for the superuser, so the chmod-000 setup
# below would not actually deny access when running as root.
_skip_as_root = pytest.mark.skipif(
hasattr(os, "geteuid") and os.geteuid() == 0,
reason = "root bypasses filesystem permission bits",
)
def test_helper_exists_in_source():
# Guards against a refactor silently dropping the helper the fix
# depends on (the extractor would then raise StopIteration).
assert callable(safe_is_dir)
def test_readable_dir_is_true(tmp_path):
assert safe_is_dir(tmp_path) is True
def test_missing_path_is_false(tmp_path):
assert safe_is_dir(tmp_path / "does-not-exist") is False
def test_file_is_false(tmp_path):
f = tmp_path / "weights.gguf"
f.write_bytes(b"x")
assert safe_is_dir(f) is False
@_skip_as_root
def test_mode000_dir_itself_is_still_a_dir(tmp_path):
"""A mode-000 directory is still stat-able via its (traversable)
parent, so _safe_is_dir reports True without raising. Filtering out
dirs we cannot actually *read* is the caller's separate
os.access(R_OK|X_OK) check, not this helper's job."""
locked = tmp_path / "locked"
locked.mkdir()
os.chmod(locked, 0o000)
try:
assert safe_is_dir(locked) is True # must not raise
finally:
os.chmod(locked, 0o755)
@_skip_as_root
def test_path_under_unreadable_parent_returns_false_not_raises(tmp_path):
"""The exact production scenario: stat()-ing a child of a mode-700
system directory, e.g. ``/usr/share/ollama/.ollama/models``."""
parent = tmp_path / "ollama"
parent.mkdir()
os.chmod(parent, 0o000)
try:
assert safe_is_dir(parent / ".ollama" / "models") is False
finally:
os.chmod(parent, 0o755)
@_skip_as_root
@pytest.mark.skipif(
sys.version_info < (3, 12),
reason = "is_dir() only propagates PermissionError on Python >= 3.12",
)
def test_demonstrates_the_underlying_stdlib_regression(tmp_path):
"""Documents *why* _safe_is_dir exists: the old bare pattern raises
on the interpreters Studio ships on (3.12+)."""
parent = tmp_path / "ollama"
parent.mkdir()
os.chmod(parent, 0o000)
try:
with pytest.raises(PermissionError):
Path(parent / ".ollama" / "models").is_dir() # pre-fix expr
finally:
os.chmod(parent, 0o755)

View file

@ -185,33 +185,39 @@ class TestUploadDenylist:
expect_phrase = "Blocked: file upload disallowed in sandbox",
)
def test_hf_api_upload_file_blocked(self):
_blocked(
(
"from huggingface_hub import HfApi\n"
'HfApi().upload_file(path_or_fileobj="x.bin", '
'path_in_repo="x.bin", repo_id="foo/bar")'
),
expect_phrase = "Blocked: file upload disallowed in sandbox",
def test_hf_api_upload_sandbox_local_allowed(self):
# Sandbox-local relative path is the canonical safe shape.
_ok(
"from huggingface_hub import HfApi\n"
'HfApi().upload_file(path_or_fileobj="x.bin", '
'path_in_repo="x.bin", repo_id="foo/bar")'
)
def test_hf_module_upload_folder_blocked(self):
_blocked(
(
"import huggingface_hub\n"
'huggingface_hub.upload_folder(folder_path="./", repo_id="foo/bar")'
),
expect_phrase = "Blocked: file upload disallowed in sandbox",
def test_hf_module_upload_folder_sandbox_local_allowed(self):
_ok(
"import huggingface_hub\n"
'huggingface_hub.upload_folder(folder_path="outputs", repo_id="foo/bar")'
)
def test_hf_create_commit_method_blocked(self):
def test_hf_create_commit_empty_operations_allowed(self):
_ok(
"import huggingface_hub\n"
"api = huggingface_hub.HfApi()\n"
'api.create_commit(repo_id="foo/bar", operations=[])'
)
def test_hf_upload_absolute_path_blocked(self):
_blocked(
(
"import huggingface_hub\n"
"api = huggingface_hub.HfApi()\n"
'api.create_commit(repo_id="foo/bar", operations=[])'
),
expect_phrase = "Blocked: file upload disallowed in sandbox",
"from huggingface_hub import HfApi\n"
'HfApi().upload_file(path_or_fileobj="/etc/passwd", path_in_repo="x", repo_id="r")',
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
)
def test_hf_upload_parent_dir_escape_blocked(self):
_blocked(
"import huggingface_hub\n"
'huggingface_hub.upload_file(path_or_fileobj="../escape.bin", path_in_repo="x", repo_id="r")',
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
)
def test_plain_post_json_not_blocked(self):
@ -221,6 +227,103 @@ class TestUploadDenylist:
)
class TestSandboxEnvIsolation:
"""The sandbox subprocess env is built from a whitelist, not by stripping.
Confirm every credential-shaped parent var is absent regardless of how the
operator's process is configured. Covers Linux/macOS/WSL/Windows shapes.
"""
_SECRET_KEYS = (
# HF + ML tooling
"HF_TOKEN",
"HUGGING_FACE_HUB_TOKEN",
"HUGGINGFACEHUB_API_TOKEN",
"WANDB_API_KEY",
"WANDB_USERNAME",
"MLFLOW_TRACKING_TOKEN",
"COMET_API_KEY",
"NEPTUNE_API_TOKEN",
# Generic cloud
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"GCP_SERVICE_ACCOUNT_KEY",
"GOOGLE_APPLICATION_CREDENTIALS",
"AZURE_STORAGE_KEY",
"AZURE_CLIENT_SECRET",
# Forge / git / package
"GH_TOKEN",
"GITHUB_TOKEN",
"GITLAB_TOKEN",
"BITBUCKET_TOKEN",
"NPM_TOKEN",
"PYPI_TOKEN",
"CARGO_REGISTRY_TOKEN",
# LLM provider
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GOOGLE_API_KEY",
"MISTRAL_API_KEY",
"COHERE_API_KEY",
"TOGETHER_API_KEY",
# Loader injection / sudo state
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
# Windows
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"ProgramData",
)
def test_no_secret_keys_leak_into_sandbox(self, monkeypatch, tmp_path):
from core.inference.tools import _build_safe_env
for key in self._SECRET_KEYS:
monkeypatch.setenv(key, f"sentinel-{key}")
env = _build_safe_env(str(tmp_path))
for key in self._SECRET_KEYS:
assert key not in env, f"parent env var {key!r} leaked into sandbox env"
def test_sandbox_env_is_minimal_whitelist(self, monkeypatch, tmp_path):
from core.inference.tools import _build_safe_env
# Pollute parent env with arbitrary keys
for key in ("EVIL", "RANDOM", "ATTACK_VEC", "MY_TOKEN", "X_API_KEY"):
monkeypatch.setenv(key, "leak-me")
env = _build_safe_env(str(tmp_path))
allowed = {
"PATH",
"HOME",
"TMPDIR",
"LANG",
"TERM",
"PYTHONIOENCODING",
"VIRTUAL_ENV",
"SystemRoot",
}
extras = set(env.keys()) - allowed
assert not extras, f"sandbox env added unexpected keys: {extras}"
def test_home_points_at_sandbox_workdir(self, tmp_path):
from core.inference.tools import _build_safe_env
env = _build_safe_env(str(tmp_path))
assert env["HOME"] == str(tmp_path)
assert env["TMPDIR"] == str(tmp_path)
def test_term_is_dumb(self, tmp_path):
from core.inference.tools import _build_safe_env
# Prevents the sandbox from re-using the operator's TERM (e.g. xterm-256color)
# which could trigger color-escape parsing in downstream tools.
env = _build_safe_env(str(tmp_path))
assert env["TERM"] == "dumb"
class TestSandboxCpuRlimitDefault:
"""Pin the default so a regression below 600s without opt-in is caught."""
@ -234,8 +337,464 @@ class TestSandboxCpuRlimitDefault:
# Explanatory comment retained.
assert "CLONE_NEWNET" in src
def test_nofile_env_tunable(self):
src = (_BACKEND_ROOT / "core" / "inference" / "tools.py").read_text()
# Parity with the other rlimits: must come from the env, not be hardcoded.
assert "UNSLOTH_STUDIO_SANDBOX_NOFILE" in src
class TestMaxBodyDefault:
def test_default_is_500_mb(self):
src = (_BACKEND_ROOT / "main.py").read_text()
assert 'UNSLOTH_STUDIO_MAX_BODY_MB", "500"' in src
class TestBashBlocklistPosition:
"""The blocklist must fire at command position only.
Pre-fix the per-token loop fired on any token, so `grep -r curl .`
and `echo source` were rejected. The position-anchored regex plus a
shlex-aware command-position-only token check is sufficient.
"""
@staticmethod
def _find():
from core.inference.tools import _find_blocked_commands
return _find_blocked_commands
# ---- argument-position: must NOT be blocked ----
def test_grep_for_curl_string_allowed(self):
assert self._find()("grep -r curl .") == set()
def test_echo_source_allowed(self):
assert self._find()("echo source the data") == set()
def test_cat_with_word_source_allowed(self):
# The 'source' word is an argument to echo; not blocked.
# `echo` itself isn't blocked. Only legit allowed tokens here.
assert self._find()("cat README.md && echo source") == set()
assert "source" not in self._find()("cat README.md && echo source")
assert "echo" not in self._find()("cat README.md && echo source")
def test_ls_path_containing_curl_allowed(self):
assert self._find()("ls /usr/bin/curl") == set()
def test_find_for_wget_string_allowed(self):
assert self._find()("find . -name wget") == set()
def test_quoted_curl_arg_allowed(self):
assert self._find()('echo "curl is a tool"') == set()
# ---- command-position: must be blocked ----
def test_bare_rm_blocked(self):
assert "rm" in self._find()("rm -rf /")
def test_curl_at_command_position_blocked(self):
assert "curl" in self._find()("curl https://example.com")
def test_after_semicolon_blocked(self):
# `rm` after `;` even without surrounding whitespace.
assert "rm" in self._find()("echo done; rm -rf /tmp/x")
assert "rm" in self._find()("echo done;rm -rf /tmp/x")
def test_after_double_ampersand_blocked(self):
assert "wget" in self._find()("cd /tmp && wget https://bad")
def test_split_quotes_obfuscation_blocked(self):
# shlex collapses 'r''m' -> 'rm' as a single token at command position.
assert "rm" in self._find()("r''m -rf /")
def test_path_prefixed_command_blocked(self):
assert "sudo" in self._find()("/usr/bin/sudo whoami")
def test_nested_bash_c_blocked(self):
# Recursion into the nested command string still catches command-position curl.
assert "curl" in self._find()("bash -c 'curl https://x'")
def test_subshell_command_blocked(self):
assert "rm" in self._find()("echo $(rm -rf /tmp)")
def test_backtick_command_blocked(self):
assert "rm" in self._find()("echo `rm -rf /tmp`")
# ---- shell prefixes / wrappers: must still be blocked ----
@pytest.mark.parametrize(
"command, blocked_cmd",
[
("FOO=bar curl https://example.com", "curl"),
("HTTPS_PROXY=http://x wget https://bad", "wget"),
("env curl https://example.com", "curl"),
("env FOO=1 /usr/bin/curl https://x", "curl"),
("/usr/bin/env rm -rf /tmp/x", "rm"),
("command rm -rf /tmp/x", "rm"),
("time curl https://example.com", "curl"),
("nice rm -rf /tmp/x", "rm"),
("nohup wget https://bad", "wget"),
("timeout 1 rm -rf /tmp/x", "rm"),
("setsid rm -rf /tmp/x", "rm"),
("stdbuf -oL rm -rf /tmp/x", "rm"),
("sudo rm -rf /tmp/x", "rm"),
("cd /tmp; FOO=bar rm -rf x", "rm"),
],
)
def test_command_prefix_wrappers_blocked(self, command, blocked_cmd):
assert blocked_cmd in self._find()(command)
# ---- split-quoted command name after attached separators ----
def test_split_quotes_after_semicolon_blocked(self):
assert "rm" in self._find()("echo done; r''m -rf /tmp/x")
assert "rm" in self._find()("echo done;r''m -rf /tmp/x")
assert "curl" in self._find()("echo done; c''url --version")
assert "curl" in self._find()("echo done; /usr/bin/c''url --version")
# ---- find -exec / xargs invoke a command directly ----
def test_find_exec_blocked(self):
assert "rm" in self._find()("find . -type f -exec rm -f {} +")
assert "rm" in self._find()("find . -type f -exec rm -f {} ';'")
assert "rm" in self._find()("find . -execdir rm -f {} ';'")
def test_xargs_command_blocked(self):
assert "rm" in self._find()("printf /tmp/x | xargs rm")
assert "rm" in self._find()("printf /tmp/x | xargs -- rm")
# ---- brace groups and bash compound statements ----
def test_brace_group_blocked(self):
assert "rm" in self._find()("{ rm -rf /tmp/x; }")
def test_if_then_blocked(self):
assert "curl" in self._find()("if true; then curl --version; fi")
def test_while_do_blocked(self):
assert "curl" in self._find()("while true; do curl --version; break; done")
class TestHfUploadImportGate:
"""HfApi-style upload-method blocking should require an HF import in
scope; otherwise paramiko / boto3 / internal SDKs with the same
method names hit a false positive."""
def test_paramiko_upload_file_allowed_without_hf_import(self):
_ok("import paramiko; sftp=None; sftp.upload_file('a','b')")
def test_boto3_create_commit_allowed_without_hf_import(self):
_ok("client=None; client.create_commit(Repo='x')")
def test_hf_api_upload_safe_path_allowed(self):
# Sandbox-local relative path -- the call shape we want to permit.
_ok("from huggingface_hub import HfApi; HfApi().upload_file('a','b','c')")
def test_hf_upload_file_fq_safe_path_allowed(self):
_ok("import huggingface_hub; huggingface_hub.upload_file('a','b','c')")
def test_dynamic_builtin_import_safe_path_allowed(self):
# `__import__('huggingface_hub')` puts HF in scope; relative-literal path is safe.
_ok("hf=__import__('huggingface_hub'); hf.HfApi().upload_file('a','b','c')")
def test_dynamic_importlib_safe_path_allowed(self):
_ok(
"import importlib; hf=importlib.import_module('huggingface_hub');"
" hf.HfApi().upload_file('a','b','c')"
)
def test_from_importlib_import_module_safe_create_commit_allowed(self):
_ok(
"from importlib import import_module;"
" api=import_module('huggingface_hub').HfApi(); api.create_commit()"
)
def test_hf_bare_name_upload_safe_path_allowed(self):
# `from huggingface_hub import upload_file` then bare `upload_file(...)`
# with a sandbox-local relative-path literal is allowed.
_ok(
"from huggingface_hub import upload_file;"
" upload_file(path_or_fileobj='x', path_in_repo='x', repo_id='r')"
)
def test_hf_bare_name_upload_folder_safe_allowed(self):
_ok(
"from huggingface_hub import upload_folder;"
" upload_folder(folder_path='x', repo_id='r')"
)
def test_hf_bare_name_create_commit_safe_allowed(self):
_ok(
"from huggingface_hub import create_commit;"
" create_commit(operations=[], repo_id='r')"
)
def test_bare_name_upload_file_without_hf_import_allowed(self):
# No HF import -- local helper named upload_file should pass.
_ok("def upload_file(*a, **k):\n pass\n" "upload_file('x', 'y', 'z')")
class TestHfUploadSandboxLocalPaths:
"""The HF upload gate must only allow uploads of files that already live in
the sandbox workdir. Absolute paths, `..` traversal, home expansion, and
Windows drive letters are rejected because the LLM can use them to lift
secrets from outside the sandbox."""
def test_relative_literal_allowed(self):
_ok(
"import huggingface_hub\n"
'huggingface_hub.upload_file(path_or_fileobj="model.bin",'
' path_in_repo="model.bin", repo_id="me/r")'
)
def test_dotted_relative_allowed(self):
_ok(
"import huggingface_hub\n"
'huggingface_hub.upload_file(path_or_fileobj="./outputs/m.bin",'
' path_in_repo="m.bin", repo_id="me/r")'
)
def test_nested_relative_allowed(self):
_ok(
"import huggingface_hub\n"
'huggingface_hub.upload_file(path_or_fileobj="outputs/run42/model.bin",'
' path_in_repo="m.bin", repo_id="me/r")'
)
def test_open_of_relative_literal_allowed(self):
_ok(
"import huggingface_hub\n"
'huggingface_hub.upload_file(path_or_fileobj=open("model.bin", "rb"),'
' path_in_repo="m.bin", repo_id="me/r")'
)
def test_inline_bytes_literal_allowed(self):
_ok(
"import huggingface_hub\n"
'huggingface_hub.upload_file(path_or_fileobj=b"\\x00\\x01\\x02",'
' path_in_repo="m.bin", repo_id="me/r")'
)
def test_absolute_unix_path_blocked(self):
_blocked(
"import huggingface_hub\n"
'huggingface_hub.upload_file(path_or_fileobj="/etc/passwd",'
' path_in_repo="x", repo_id="r")',
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
)
def test_absolute_windows_drive_blocked(self):
_blocked(
"import huggingface_hub\n"
'huggingface_hub.upload_file(path_or_fileobj="C:\\\\Windows\\\\creds",'
' path_in_repo="x", repo_id="r")',
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
)
def test_home_expansion_blocked(self):
_blocked(
"import huggingface_hub\n"
'huggingface_hub.upload_file(path_or_fileobj="~/.aws/credentials",'
' path_in_repo="x", repo_id="r")',
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
)
def test_parent_traversal_blocked(self):
_blocked(
"import huggingface_hub\n"
'huggingface_hub.upload_file(path_or_fileobj="../../etc/shadow",'
' path_in_repo="x", repo_id="r")',
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
)
def test_parent_traversal_mid_path_blocked(self):
_blocked(
"import huggingface_hub\n"
'huggingface_hub.upload_file(path_or_fileobj="outputs/../../../etc",'
' path_in_repo="x", repo_id="r")',
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
)
def test_open_of_absolute_blocked(self):
_blocked(
"import huggingface_hub\n"
'huggingface_hub.upload_file(path_or_fileobj=open("/etc/passwd","rb"),'
' path_in_repo="x", repo_id="r")',
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
)
def test_open_of_parent_traversal_blocked(self):
_blocked(
"import huggingface_hub\n"
'huggingface_hub.upload_file(path_or_fileobj=open("../escape","rb"),'
' path_in_repo="x", repo_id="r")',
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
)
def test_dynamic_variable_path_blocked(self):
# A non-literal expression could resolve to any path at runtime;
# the static checker cannot prove safety, so block.
_blocked(
"import huggingface_hub, os\n"
"p = os.path.join('outputs', 'x.bin')\n"
'huggingface_hub.upload_file(path_or_fileobj=p, path_in_repo="x", repo_id="r")',
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
)
def test_upload_folder_absolute_blocked(self):
_blocked(
"import huggingface_hub\n"
'huggingface_hub.upload_folder(folder_path="/var/log", repo_id="r")',
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
)
def test_upload_folder_parent_traversal_blocked(self):
_blocked(
"import huggingface_hub\n"
'huggingface_hub.upload_folder(folder_path="../..", repo_id="r")',
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
)
def test_upload_large_folder_absolute_blocked(self):
_blocked(
"import huggingface_hub\n"
'huggingface_hub.upload_large_folder(folder_path="/etc", repo_id="r")',
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
)
def test_create_commit_operation_safe_allowed(self):
_ok(
"import huggingface_hub\n"
"from huggingface_hub import CommitOperationAdd\n"
"huggingface_hub.HfApi().create_commit(\n"
" repo_id='r',\n"
" operations=[CommitOperationAdd(path_or_fileobj='m.bin', path_in_repo='m.bin')],\n"
")"
)
def test_create_commit_operation_absolute_blocked(self):
_blocked(
"import huggingface_hub\n"
"from huggingface_hub import CommitOperationAdd\n"
"huggingface_hub.HfApi().create_commit(\n"
" repo_id='r',\n"
" operations=[CommitOperationAdd(path_or_fileobj='/etc/passwd', path_in_repo='x')],\n"
")",
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
)
class TestHfUploadEnvAndSecretLeakBlock:
"""The HF upload gate must reject any positional / keyword arg sourced from
`os.environ` / `os.getenv` / subprocess env reads. Even though
`_build_safe_env` strips HF_TOKEN/WANDB/AWS upfront for the sandbox shell,
a Python script can still reach the parent process env if it bypasses the
safe-env wrapper at the source -- so block statically."""
def test_path_from_os_environ_subscript_blocked(self):
_blocked(
"import huggingface_hub, os\n"
'huggingface_hub.upload_file(path_or_fileobj=os.environ["HF_TOKEN"],'
' path_in_repo="x", repo_id="r")',
expect_phrase = "HF upload cannot include os.environ",
)
def test_path_from_os_environ_get_blocked(self):
_blocked(
"import huggingface_hub, os\n"
'huggingface_hub.upload_file(path_or_fileobj=os.environ.get("HF_TOKEN"),'
' path_in_repo="x", repo_id="r")',
expect_phrase = "HF upload cannot include os.environ",
)
def test_path_from_os_getenv_blocked(self):
_blocked(
"import huggingface_hub, os\n"
'huggingface_hub.upload_file(path_or_fileobj=os.getenv("HF_TOKEN"),'
' path_in_repo="x", repo_id="r")',
expect_phrase = "HF upload cannot include os.environ",
)
def test_path_from_bare_getenv_blocked(self):
_blocked(
"import huggingface_hub\n"
"from os import getenv\n"
'huggingface_hub.upload_file(path_or_fileobj=getenv("HF_TOKEN"),'
' path_in_repo="x", repo_id="r")',
expect_phrase = "HF upload cannot include os.environ",
)
def test_path_from_subprocess_printenv_blocked(self):
_blocked(
"import huggingface_hub, subprocess\n"
"huggingface_hub.upload_file("
'path_or_fileobj=subprocess.check_output(["printenv","HF_TOKEN"]),'
' path_in_repo="x", repo_id="r")',
expect_phrase = "HF upload cannot include os.environ",
)
def test_token_kwarg_with_literal_blocked(self):
_blocked(
"import huggingface_hub\n"
'huggingface_hub.upload_file(path_or_fileobj="x.bin",'
' path_in_repo="x", repo_id="r", token="hf_xyzabc123")',
expect_phrase = "HF upload token= cannot be set",
)
def test_hf_token_kwarg_blocked(self):
_blocked(
"import huggingface_hub\n"
'huggingface_hub.upload_file(path_or_fileobj="x.bin",'
' path_in_repo="x", repo_id="r", hf_token="hf_secret")',
expect_phrase = "HF upload hf_token= cannot be set",
)
def test_api_key_kwarg_blocked(self):
_blocked(
"import huggingface_hub\n"
'huggingface_hub.upload_folder(folder_path="outputs",'
' repo_id="r", api_key="abc")',
expect_phrase = "HF upload api_key= cannot be set",
)
def test_token_kwarg_from_env_blocked(self):
# Both rules fire; the sensitive-kwarg check trips first.
_blocked(
"import huggingface_hub, os\n"
'huggingface_hub.upload_file(path_or_fileobj="x.bin",'
' path_in_repo="x", repo_id="r", token=os.environ["HF_TOKEN"])',
expect_phrase = "HF upload token= cannot be set",
)
def test_env_dict_unpacked_via_environ_attr_blocked(self):
# `os.environ` as a bare reference (passed somewhere it gets serialized).
_blocked(
"import huggingface_hub, os\n"
"huggingface_hub.upload_file(path_or_fileobj=str(os.environ),"
' path_in_repo="x", repo_id="r")',
expect_phrase = "HF upload cannot include os.environ",
)
def test_repo_id_from_env_also_blocked(self):
# Even non-path args must not source env vars -- an attacker could
# encode secrets in repo_id or path_in_repo.
_blocked(
"import huggingface_hub, os\n"
'huggingface_hub.upload_file(path_or_fileobj="x.bin",'
' path_in_repo=os.environ["HF_TOKEN"], repo_id="r")',
expect_phrase = "HF upload cannot include os.environ",
)
def test_create_commit_with_env_in_operation_blocked(self):
_blocked(
"import huggingface_hub, os\n"
"from huggingface_hub import CommitOperationAdd\n"
"huggingface_hub.HfApi().create_commit(\n"
" repo_id='r',\n"
" operations=[CommitOperationAdd("
'path_or_fileobj=os.environ["HF_TOKEN"], path_in_repo="x")],\n'
")",
expect_phrase = "HF upload cannot include os.environ",
)
def test_create_commit_token_kwarg_blocked(self):
_blocked(
"import huggingface_hub\n"
'huggingface_hub.HfApi().create_commit(repo_id="r",'
' operations=[], token="hf_xxx")',
expect_phrase = "HF upload token= cannot be set",
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,393 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Windows GPU-detection regression test on a synthetic layout.
The bug (#5106): on Windows without a system CUDA toolkit, the prebuilt
llama-server.exe could not LoadLibrary cudart64_X / cublas64_X /
cublasLt64_X, so ggml-cuda.dll's static import on cublas64_X.dll failed
and the model fell back to CPU even when nvidia-smi reported the GPU.
The fix:
* #5322 overlays upstream's paired cudart bundle into
install_dir/build/bin/Release/ next to llama-server.exe.
* #5324 prepends pip-installed nvidia/<pkg>/{bin,bin/x86_64,Library/
bin} and torch/lib to PATH when launching llama-server.exe.
CI has no GPU so nvidia-smi is mocked; everything else (resolver, PATH
builder, install layout) runs against a real filesystem.
"""
from __future__ import annotations
import os
import subprocess
import sys
import types as _types
import zipfile
from pathlib import Path
from unittest import mock
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# Stub heavy deps only if they actually fail to import -- unconditional
# stubs would shadow the real module for sibling tests in this dir.
# Use try-import rather than find_spec: loggers/__init__.py re-exports
# handlers.get_logger, which does `from fastapi import Request,
# Response` at module load. find_spec("loggers") returns a spec even
# without fastapi, but the import then raises. CI has fastapi, so this
# is dev-machine ergonomics only.
import importlib as _importlib # noqa: E402
def _maybe_stub(name: str, builder):
try:
_importlib.import_module(name)
except ImportError:
sys.modules[name] = builder()
def _build_loggers_stub():
m = _types.ModuleType("loggers")
m.get_logger = lambda name: __import__("logging").getLogger(name)
return m
def _build_structlog_stub():
return _types.ModuleType("structlog")
def _build_httpx_stub():
m = _types.ModuleType("httpx")
for _exc_name in (
"ConnectError",
"TimeoutException",
"ReadTimeout",
"ReadError",
"RemoteProtocolError",
"CloseError",
"HTTPError",
):
setattr(m, _exc_name, type(_exc_name, (Exception,), {}))
m.Response = type("Response", (), {})
class _FakeTimeout:
def __init__(self, *a, **kw):
pass
m.Timeout = _FakeTimeout
m.Client = type(
"Client",
(),
{
"__init__": lambda self, **kw: None,
"__enter__": lambda self: self,
"__exit__": lambda self, *a: None,
},
)
return m
_maybe_stub("loggers", _build_loggers_stub)
_maybe_stub("structlog", _build_structlog_stub)
_maybe_stub("httpx", _build_httpx_stub)
from core.inference.llama_cpp import LlamaCppBackend # noqa: E402
# Upstream b9103 cudart bundle: exactly these three DLLs per CUDA major,
# no executables, no subdirectories. Verified by direct unzip.
REAL_UPSTREAM_CUDART_BUNDLE = {
"12.4": ("cudart64_12.dll", "cublas64_12.dll", "cublasLt64_12.dll"),
"13.1": ("cudart64_13.dll", "cublas64_13.dll", "cublasLt64_13.dll"),
}
# PyPI win_amd64 wheel layouts, verified via `pip download ... --platform
# win_amd64` + `unzip -l`. Resolver only cares about directory structure.
REAL_PIP_NVIDIA_WHEEL_LAYOUTS = {
# Legacy cu-suffixed wheels
"nvidia/cuda_runtime/bin": ["cudart64_12.dll"],
"nvidia/cublas/bin": [
"cublas64_12.dll",
"cublasLt64_12.dll",
"nvblas64_12.dll",
],
"nvidia/cudnn/bin": [
"cudnn64_9.dll",
"cudnn_adv64_9.dll",
"cudnn_ops64_9.dll",
],
# Unsuffixed cu13 wheels
"nvidia/cu13/bin/x86_64": [
"cudart64_13.dll",
"cublas64_13.dll",
"cublasLt64_13.dll",
"nvblas64_13.dll",
],
}
def _populate_studio_venv(prefix: Path) -> None:
"""Lay out fake nvidia + torch wheels in <prefix>/Lib/site-packages
matching the real win_amd64 wheel layouts. Contents are stub bytes;
only directory structure matters."""
site = prefix / "Lib" / "site-packages"
for rel, dlls in REAL_PIP_NVIDIA_WHEEL_LAYOUTS.items():
d = site / Path(rel)
d.mkdir(parents = True, exist_ok = True)
for name in dlls:
(d / name).write_bytes(b"PE-stub")
# install_python_stack always installs torch alongside nvidia.
(site / "torch" / "lib").mkdir(parents = True, exist_ok = True)
for fn in ("c10.dll", "torch.dll", "torch_cpu.dll", "torch_python.dll"):
(site / "torch" / "lib" / fn).write_bytes(b"PE-stub")
def _populate_studio_install(install_dir: Path, runtime: str = "13.1") -> None:
"""Lay out install_dir/build/bin/Release/ as #5322 leaves it: main
archive payload + paired cudart bundle overlay."""
rel = install_dir / "build" / "bin" / "Release"
rel.mkdir(parents = True, exist_ok = True)
for fn in (
"llama-server.exe",
"llama-quantize.exe",
"llama-cli.exe",
"llama.dll",
"ggml.dll",
"ggml-base.dll",
"ggml-cuda.dll",
"mtmd.dll",
):
(rel / fn).write_bytes(b"PE-stub")
# The cudart overlay #5322 contributes.
for fn in REAL_UPSTREAM_CUDART_BUNDLE[runtime]:
(rel / fn).write_bytes(b"PE-stub")
def _build_path_dirs_like_start_llama_server(
binary_dir: Path, prefix: Path, cuda_path: str = ""
) -> list[str]:
"""Path-friendly wrapper around LlamaCppBackend._build_windows_path_dirs.
Asserting against the staticmethod (not a hand-copy) is the point:
if the win32 PATH order drops _windows_pip_nvidia_dll_dirs, tests fail."""
return LlamaCppBackend._build_windows_path_dirs(
str(binary_dir), str(prefix), cuda_path
)
def _mock_nvidia_smi_run(fake_output: str, returncode: int = 0) -> "mock._patch":
"""Patch subprocess.run so the nvidia-smi probe returns fake_output;
other subprocess.run calls pass through."""
real_run = subprocess.run
def fake_run(cmd, *args, **kwargs):
if isinstance(cmd, list) and cmd and "nvidia-smi" in cmd[0]:
return subprocess.CompletedProcess(
args = cmd, returncode = returncode, stdout = fake_output, stderr = ""
)
return real_run(cmd, *args, **kwargs)
return mock.patch("subprocess.run", side_effect = fake_run)
# --------------------------------------------------------------------- #
# Tests
# --------------------------------------------------------------------- #
class TestWindowsGpuDetectionAfter5106Fix:
"""End-to-end #5106 fix on a synthetic Windows layout. nvidia-smi
mocked; resolver, PATH builder and install layout exercised live."""
def test_nvidia_smi_probe_reports_synthetic_gpu(self, monkeypatch):
"""Probe parses CSV output and returns (index, free_mib)."""
# Clear inherited masks so the synthetic CSV is not filtered.
monkeypatch.delenv("CUDA_VISIBLE_DEVICES", raising = False)
monkeypatch.delenv("NVIDIA_VISIBLE_DEVICES", raising = False)
# The #5106 reporter's exact reproducer: RTX 4090, 22805 MiB.
fake_csv = "0, 22805\n"
with _mock_nvidia_smi_run(fake_csv):
gpus = LlamaCppBackend._get_gpu_free_memory()
assert gpus == [
(0, 22805)
], f"GPU probe failed to parse mocked nvidia-smi output: {gpus}"
def test_nvidia_smi_probe_respects_cuda_visible_devices(self, monkeypatch):
"""CUDA_VISIBLE_DEVICES=1 -> only GPU 1 visible."""
fake_csv = "0, 22805\n1, 24576\n2, 16384\n"
monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "1")
with _mock_nvidia_smi_run(fake_csv):
gpus = LlamaCppBackend._get_gpu_free_memory()
assert gpus == [(1, 24576)], gpus
def test_windows_install_dir_has_all_three_cudart_dlls(self, tmp_path):
"""All three bundle DLLs must land in install_dir/build/bin/
Release; missing any one breaks ggml-cuda.dll's PE import chain."""
install = tmp_path / "studio_install"
_populate_studio_install(install, runtime = "13.1")
rel = install / "build" / "bin" / "Release"
for fn in REAL_UPSTREAM_CUDART_BUNDLE["13.1"]:
assert (rel / fn).exists(), f"missing {fn} in {rel}"
assert (rel / "llama-server.exe").exists()
assert (rel / "ggml-cuda.dll").exists()
def test_resolver_finds_real_pypi_wheel_layouts(self, tmp_path):
"""Resolver must pick up every real-world wheel layout:
nvidia/<pkg>/bin, nvidia/<pkg>/bin/x86_64, torch/lib."""
prefix = tmp_path / "studio_venv"
_populate_studio_venv(prefix)
out = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(prefix))
site = prefix / "Lib" / "site-packages"
for expected in (
site / "nvidia" / "cuda_runtime" / "bin",
site / "nvidia" / "cublas" / "bin",
site / "nvidia" / "cudnn" / "bin",
site / "nvidia" / "cu13" / "bin" / "x86_64",
site / "torch" / "lib",
):
assert (
str(expected) in out
), f"resolver missed {expected.relative_to(prefix)}: {out}"
def test_path_assembly_makes_cudart_reachable_without_toolkit(self, tmp_path):
"""The #5106 scenario: GPU detected, pip nvidia wheels present,
no system CUDA toolkit. cudart must be reachable from PATH, and
from BOTH binary_dir (#5322) and a pip nvidia dir (#5324)."""
prefix = tmp_path / "studio_venv"
install = tmp_path / "studio_install"
_populate_studio_venv(prefix)
_populate_studio_install(install, runtime = "13.1")
binary_dir = install / "build" / "bin" / "Release"
path_dirs = _build_path_dirs_like_start_llama_server(
binary_dir, prefix, cuda_path = ""
)
# binary_dir first -- Windows DLL search step 1.
assert path_dirs[0] == str(
binary_dir
), f"binary_dir must be first in PATH; got {path_dirs[0]}"
cudart_locations = []
for entry in path_dirs:
for cudart_name in ("cudart64_12.dll", "cudart64_13.dll"):
if (Path(entry) / cudart_name).exists():
cudart_locations.append((entry, cudart_name))
assert cudart_locations, (
f"cudart unreachable from any PATH entry -- #5106 not fixed.\n"
f"PATH entries searched: {path_dirs}"
)
# Defence in depth: both fix paths contribute cudart.
sources = {Path(e).relative_to(tmp_path).parts[0] for e, _ in cudart_locations}
assert (
"studio_install" in sources
), f"#5322's cudart drop not reachable: {cudart_locations}"
assert (
"studio_venv" in sources
), f"#5324's pip nvidia dir not contributing cudart: {cudart_locations}"
def test_cublas_and_cublasLt_also_reachable(self, tmp_path):
"""ggml-cuda imports cublas64; cublas64 imports cublasLt64. All
three must resolve or LoadLibrary returns NULL."""
prefix = tmp_path / "studio_venv"
install = tmp_path / "studio_install"
_populate_studio_venv(prefix)
_populate_studio_install(install, runtime = "13.1")
binary_dir = install / "build" / "bin" / "Release"
path_dirs = _build_path_dirs_like_start_llama_server(binary_dir, prefix)
for required in REAL_UPSTREAM_CUDART_BUNDLE["13.1"]:
reachable = any((Path(d) / required).exists() for d in path_dirs)
assert reachable, (
f"{required} unreachable from PATH; #5106 not fixed.\n"
f"PATH entries: {path_dirs}"
)
def test_no_pip_nvidia_wheels_still_works_via_install_dir(self, tmp_path):
"""No pip nvidia wheels (CPU-only torch / unsloth run standalone):
cudart still resolves via #5322's binary_dir drop."""
prefix = tmp_path / "bare_venv"
prefix.mkdir()
install = tmp_path / "studio_install"
_populate_studio_install(install, runtime = "13.1")
binary_dir = install / "build" / "bin" / "Release"
path_dirs = _build_path_dirs_like_start_llama_server(binary_dir, prefix)
assert path_dirs == [
str(binary_dir)
], f"bare venv produced unexpected PATH: {path_dirs}"
for required in REAL_UPSTREAM_CUDART_BUNDLE["13.1"]:
assert (
binary_dir / required
).exists(), f"{required} missing from binary_dir on bare venv install"
def test_no_install_dir_still_works_via_pip_wheels(self, tmp_path):
"""Pre-#5322 install (binary_dir lacks cudart): #5324's pip
wheel directories on PATH still resolve cudart."""
prefix = tmp_path / "studio_venv"
_populate_studio_venv(prefix)
install = tmp_path / "studio_install_pre5322"
rel = install / "build" / "bin" / "Release"
rel.mkdir(parents = True)
# Main archive payload only; cudart bundle absent.
for fn in (
"llama-server.exe",
"llama.dll",
"ggml-cuda.dll",
"ggml-base.dll",
):
(rel / fn).write_bytes(b"PE-stub")
path_dirs = _build_path_dirs_like_start_llama_server(rel, prefix)
cudart_reachable = any(
(Path(d) / "cudart64_12.dll").exists()
or (Path(d) / "cudart64_13.dll").exists()
for d in path_dirs
)
assert cudart_reachable, (
"#5324 pip wheel fallback failed: cudart unreachable from PATH "
f"on cudart-less install. PATH entries: {path_dirs}"
)
cublas_reachable = any(
(Path(d) / "cublas64_12.dll").exists()
or (Path(d) / "cublas64_13.dll").exists()
for d in path_dirs
)
assert cublas_reachable, "cublas unreachable on cudart-less install"
def test_pre_pr_scenario_would_have_failed(self, tmp_path):
"""Negative control: pre-#5322 + pre-#5324 world leaves cudart
unreachable -- the original failure mode. Confirms the test
actually catches a regression."""
prefix = tmp_path / "studio_venv"
_populate_studio_venv(prefix)
install = tmp_path / "pre_pr_install"
rel = install / "build" / "bin" / "Release"
rel.mkdir(parents = True)
for fn in ("llama-server.exe", "llama.dll", "ggml-cuda.dll"):
(rel / fn).write_bytes(b"PE-stub")
# Pre-PR PATH: binary_dir only. No pip nvidia dirs, no toolkit.
pre_pr_path_dirs = [str(rel)]
cudart_reachable_pre = any(
(Path(d) / "cudart64_12.dll").exists()
or (Path(d) / "cudart64_13.dll").exists()
for d in pre_pr_path_dirs
)
assert not cudart_reachable_pre, (
"Test self-check failed: pre-PR scenario unexpectedly had "
f"cudart reachable. {pre_pr_path_dirs}"
)
class TestWindowsSysPlatformMocked:
"""Confirm the win32 branch in start_llama_server is what we test
(not the linux fallback). Patches sys.platform and re-runs the
branch-selecting helper."""
def test_sys_platform_win32_uses_pip_nvidia_resolver(self, monkeypatch, tmp_path):
monkeypatch.setattr(sys, "platform", "win32")
prefix = tmp_path / "studio_venv"
_populate_studio_venv(prefix)
out = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(prefix))
assert out, f"resolver returned empty under sys.platform=win32: {out}"
# cu13 arch dir must be in the output.
cu13_arch = (
prefix / "Lib" / "site-packages" / "nvidia" / "cu13" / "bin" / "x86_64"
)
assert str(cu13_arch) in out

View file

@ -0,0 +1,244 @@
# 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 freshness check.
Reads UNSLOTH_PREBUILT_INFO.json (written by install_llama_prebuilt.py)
and compares the installed release tag against the latest on GitHub.
Surfaced via main.py:lifespan() and /api/inference/status. Fails open
on any missing data so we never show a misleading banner.
"""
from __future__ import annotations
import json
import os
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import structlog
logger = structlog.get_logger(__name__)
# 3 days matches Unsloth's typical llama.cpp release cadence.
STALENESS_THRESHOLD_DAYS = 3
# 24h TTL keeps the GitHub call off the hot path and within rate limits.
_RELEASE_CACHE_TTL_SECONDS = 24 * 60 * 60
_INSTALL_MARKER_NAME = "UNSLOTH_PREBUILT_INFO.json"
_marker_cache: dict[str, Optional[dict]] = {}
_release_memo: dict[str, tuple[float, Optional[str]]] = {}
def _cache_dir() -> Path:
"""Lazy import so tests can stub storage_roots."""
try:
from utils.paths.storage_roots import cache_root
return cache_root() / "llama_cpp_freshness"
except Exception:
return Path.home() / ".unsloth" / "studio" / "cache" / "llama_cpp_freshness"
def read_install_marker(binary_path: Optional[str]) -> Optional[dict]:
"""Walk up from binary_path to find UNSLOTH_PREBUILT_INFO.json.
None means no marker (source build / custom path) or invalid JSON."""
if not binary_path:
return None
cached = _marker_cache.get(binary_path)
if cached is not None or binary_path in _marker_cache:
return cached
p = Path(binary_path)
marker: Optional[dict] = None
# Cover all _find_llama_server_binary layouts:
# <install>/llama-server (1 up)
# <install>/build/bin/llama-server (3 up, Linux/macOS cmake)
# <install>/build/bin/Release/llama-server.exe (4 up, Windows cmake)
for parent in p.parents[:5]:
candidate = parent / _INSTALL_MARKER_NAME
if candidate.is_file():
try:
marker = json.loads(candidate.read_text(encoding = "utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.debug(
"failed to parse install marker",
path = str(candidate),
error = str(exc),
)
marker = None
break
_marker_cache[binary_path] = marker
return marker
def _cache_path_for(repo: str) -> Path:
safe = repo.replace("/", "__")
return _cache_dir() / f"{safe}.json"
def _load_disk_cache(repo: str) -> Optional[tuple[float, Optional[str]]]:
path = _cache_path_for(repo)
try:
payload = json.loads(path.read_text(encoding = "utf-8"))
except (OSError, json.JSONDecodeError):
return None
ts = payload.get("fetched_at")
tag = payload.get("latest_tag")
if not isinstance(ts, (int, float)):
return None
return float(ts), tag if isinstance(tag, str) else None
def _save_disk_cache(repo: str, latest_tag: Optional[str]) -> None:
path = _cache_path_for(repo)
try:
path.parent.mkdir(parents = True, exist_ok = True)
tmp = path.with_suffix(".tmp")
tmp.write_text(
json.dumps({"fetched_at": time.time(), "latest_tag": latest_tag}),
encoding = "utf-8",
)
tmp.replace(path)
except OSError as exc:
logger.debug("freshness cache write failed", repo = repo, error = str(exc))
def _fetch_latest_release_tag(repo: str, timeout: float = 5.0) -> Optional[str]:
"""GitHub API call. None on any failure (offline, rate-limited, etc)."""
import urllib.error
import urllib.request
url = f"https://api.github.com/repos/{repo}/releases/latest"
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "unsloth-studio-freshness-check",
}
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers = headers)
try:
with urllib.request.urlopen(req, timeout = timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
except (
urllib.error.URLError,
urllib.error.HTTPError,
OSError,
json.JSONDecodeError,
) as exc:
logger.debug("freshness fetch failed", repo = repo, error = str(exc))
return None
tag = data.get("tag_name")
return tag if isinstance(tag, str) and tag else None
def latest_published_release(
repo: str, *, force_refresh: bool = False
) -> Optional[str]:
"""Latest release tag for `repo`. Memo + disk-cached (24h TTL).
None when offline and never previously cached."""
if not repo:
return None
now = time.time()
if not force_refresh:
memo = _release_memo.get(repo)
if memo and now - memo[0] < _RELEASE_CACHE_TTL_SECONDS:
return memo[1]
disk = _load_disk_cache(repo)
if disk and now - disk[0] < _RELEASE_CACHE_TTL_SECONDS:
_release_memo[repo] = disk
return disk[1]
latest = _fetch_latest_release_tag(repo)
if latest is None:
# Keep last-good disk value rather than poisoning with None.
disk = _load_disk_cache(repo)
if disk:
_release_memo[repo] = disk
return disk[1]
return None
_release_memo[repo] = (now, latest)
_save_disk_cache(repo, latest)
return latest
def _parse_installed_at(value: object) -> Optional[datetime]:
if not isinstance(value, str) or not value:
return None
s = value.replace("Z", "+00:00") if value.endswith("Z") else value
try:
dt = datetime.fromisoformat(s)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo = timezone.utc)
return dt
def check_prebuilt_freshness(
binary_path: Optional[str],
*,
threshold_days: int = STALENESS_THRESHOLD_DAYS,
now: Optional[datetime] = None,
) -> dict:
"""Returns {has_marker, stale, installed_tag, latest_tag,
installed_at_utc, age_days, published_repo, threshold_days}.
stale = True iff installed != latest AND age >= threshold.
Fails open on missing data (stale stays False)."""
out: dict = {
"has_marker": False,
"stale": False,
"installed_tag": None,
"latest_tag": None,
"installed_at_utc": None,
"age_days": None,
"published_repo": None,
"threshold_days": int(threshold_days),
}
marker = read_install_marker(binary_path)
if not marker:
return out
out["has_marker"] = True
out["installed_tag"] = marker.get("tag") or marker.get("release_tag")
out["installed_at_utc"] = marker.get("installed_at_utc")
out["published_repo"] = marker.get("published_repo")
repo = out["published_repo"]
if not repo or not out["installed_tag"]:
return out
latest = latest_published_release(repo)
out["latest_tag"] = latest
if not latest or latest == out["installed_tag"]:
return out
installed_at = _parse_installed_at(out["installed_at_utc"])
if installed_at is None:
return out
now = now or datetime.now(tz = timezone.utc)
age_seconds = (now - installed_at).total_seconds()
out["age_days"] = max(0, int(age_seconds // 86400))
if age_seconds >= threshold_days * 86400:
out["stale"] = True
return out
def format_stale_warning(info: dict) -> str:
"""Human-readable one-liner for stale prebuilt info."""
age = info.get("age_days")
installed = info.get("installed_tag") or "unknown"
latest = info.get("latest_tag") or "unknown"
age_str = f"{age} day{'s' if age != 1 else ''}" if age is not None else "some time"
return (
f"llama.cpp prebuilt is {age_str} behind: installed "
f"{installed}, latest {latest}. Run `unsloth studio update` "
f"to refresh."
)
def reset_caches() -> None:
"""Test-only: drop all in-memory caches."""
_marker_cache.clear()
_release_memo.clear()

View file

@ -44,6 +44,16 @@ from utils.subprocess_compat import (
logger = get_logger(__name__)
def _env_offline() -> bool:
"""True if HF_HUB_OFFLINE or TRANSFORMERS_OFFLINE is set to a truthy value."""
return os.environ.get("HF_HUB_OFFLINE", "").lower() in (
"1",
"true",
"yes",
) or os.environ.get("TRANSFORMERS_OFFLINE", "").lower() in ("1", "true", "yes")
# ── Model size extraction ────────────────────────────────────
import re as _re
@ -1259,12 +1269,10 @@ def _extract_quant_label(filename: str) -> str:
"""
import re
# Use only the basename (rfilename may include directory)
basename = filename.rsplit("/", 1)[-1]
# Strip .gguf and any shard suffix (-00001-of-00010)
stem = re.sub(r"-\d{3,}-of-\d{3,}", "", basename.rsplit(".", 1)[0])
# Match known quantization patterns
match = re.search(
quant_re = (
r"(UD-)?" # Optional UD- prefix (Ultra Discrete)
r"(MXFP[0-9]+(?:_[A-Z0-9]+)*" # MXFP variants: MXFP4, MXFP4_MOE
r"|IQ[0-9]+_[A-Z]+(?:_[A-Z0-9]+)?" # IQ variants: IQ4_XS, IQ4_NL, IQ1_S
@ -1272,10 +1280,19 @@ def _extract_quant_label(filename: str) -> str:
r"|Q[0-9]+_K_[A-Z]+" # K-quant: Q4_K_M, Q3_K_S
r"|Q[0-9]+_[0-9]+" # Standard: Q8_0, Q5_1
r"|Q[0-9]+_K" # Short K-quant: Q6_K
r"|BF16|F16|F32)", # Full precision
stem,
re.IGNORECASE,
r"|BF16|F16|F32)" # Full precision
)
match = re.search(quant_re, stem, re.IGNORECASE)
# Subdir layouts like ``BF16/foo.gguf`` keep the quant in the directory,
# not the basename. Look at the parent dirs too so the variant label
# matches the snapshot-relative path produced elsewhere.
if not match and "/" in filename:
parents = filename.rsplit("/", 1)[0]
for segment in reversed(parents.split("/")):
m = re.search(quant_re, segment, re.IGNORECASE)
if m:
match = m
break
if match:
prefix = match.group(1) or ""
return f"{prefix}{match.group(2)}"
@ -1283,6 +1300,57 @@ def _extract_quant_label(filename: str) -> str:
return stem.split("-")[-1]
def _iter_hf_cache_snapshots(repo_id: str):
"""Yield HF cache snapshot dirs for *repo_id*, newest first.
Empty generator if HF_HUB_CACHE is missing, the repo isn't cached,
or has no snapshots. Repo name match is case-insensitive to handle
casing drift between download time and lookup.
"""
try:
from huggingface_hub import constants as hf_constants
except Exception:
return
cache_dir = Path(hf_constants.HF_HUB_CACHE)
if not cache_dir.is_dir():
return
target = f"models--{repo_id.replace('/', '--')}".lower()
repo_dir: Optional[Path] = None
try:
for entry in cache_dir.iterdir():
if entry.is_dir() and entry.name.lower() == target:
repo_dir = entry
break
except OSError:
return
if repo_dir is None:
return
snapshots = repo_dir / "snapshots"
if not snapshots.is_dir():
return
try:
snap_dirs = [s for s in snapshots.iterdir() if s.is_dir()]
except OSError:
return
snap_dirs.sort(key = lambda s: s.stat().st_mtime, reverse = True)
yield from snap_dirs
def _list_gguf_variants_from_hf_cache(
repo_id: str,
) -> Optional[tuple[list[GgufVariantInfo], bool]]:
"""Variants from the local HF cache snapshot, or None if not cached."""
for snap in _iter_hf_cache_snapshots(repo_id):
variants, has_vision = list_local_gguf_variants(str(snap))
if variants or has_vision:
return variants, has_vision
return None
def list_gguf_variants(
repo_id: str,
hf_token: Optional[str] = None,
@ -1298,7 +1366,35 @@ def list_gguf_variants(
"""
from huggingface_hub import model_info as hf_model_info
info = hf_model_info(repo_id, token = hf_token, files_metadata = True)
# Offline: skip the API and serve from cache.
if _env_offline():
cached = _list_gguf_variants_from_hf_cache(repo_id)
if cached is not None:
return cached
try:
info = hf_model_info(repo_id, token = hf_token, files_metadata = True)
except Exception as e:
# Permanent errors (deleted/gated/bad revision) must surface to
# the caller; serving stale cache here would mask the real cause.
# Matches the early-return in ``detect_gguf_model_remote``.
if type(e).__name__ in (
"RepositoryNotFoundError",
"GatedRepoError",
"RevisionNotFoundError",
"EntryNotFoundError",
):
raise
# API failed transiently; fall back to local snapshot if fully downloaded.
cached = _list_gguf_variants_from_hf_cache(repo_id)
if cached is not None:
logger.warning(
"HF API unreachable for %s (%s); using local cache snapshot.",
repo_id,
e.__class__.__name__,
)
return cached
raise
variants: list[GgufVariantInfo] = []
has_vision = False
@ -1392,16 +1488,13 @@ def list_local_gguf_variants(
size = f.stat().st_size
except OSError:
size = 0
quant = _extract_quant_label(f.name)
# Pass the relative path so ``BF16/foo.gguf`` and ``Q4_K_M/foo.gguf``
# produce distinct quant labels instead of collapsing on basename.
rel = f.relative_to(p).as_posix()
quant = _extract_quant_label(rel)
quant_totals[quant] = quant_totals.get(quant, 0) + size
# Only compute the (potentially expensive) relative path when this
# is the first file we've seen for this quant -- after that we'd
# discard the result anyway. Use posix-style separators so the
# filename matches what ``list_gguf_variants`` (the remote HF
# API path) returns on every platform; otherwise Windows would
# emit ``BF16\foo.gguf`` here.
if quant not in quant_first_file:
quant_first_file[quant] = f.relative_to(p).as_posix()
quant_first_file[quant] = rel
variants = [
GgufVariantInfo(
@ -1429,16 +1522,36 @@ def _find_local_gguf_by_variant(directory: str, variant: str) -> Optional[str]:
# Recurse into subdirectories so variants stored under a quant-named
# subdir (e.g. ``BF16/foo-BF16-00001-of-00002.gguf``) are found.
# Match against the relative path so the quant label can come from
# the directory name when the basename omits it.
matches = sorted(
f
for f in _iter_gguf_files(p, recursive = True)
if not _is_mmproj(f.name) and _extract_quant_label(f.name) == variant
if not _is_mmproj(f.name)
and _extract_quant_label(f.relative_to(p).as_posix()) == variant
)
if matches:
return str(matches[0].resolve())
return None
def _detect_gguf_from_hf_cache(repo_id: str) -> Optional[str]:
"""Best GGUF filename for *repo_id* from the local HF cache, or None.
Excludes mmproj (vision projector) files so a partial cache that
only has the projector cannot route the projector as the main model.
"""
for snap in _iter_hf_cache_snapshots(repo_id):
rel_files = [
f.relative_to(snap).as_posix()
for f in _iter_gguf_files(snap, recursive = True)
if not _is_mmproj(f.name)
]
if rel_files:
return _pick_best_gguf(rel_files)
return None
def detect_gguf_model_remote(
repo_id: str,
hf_token: Optional[str] = None,
@ -1455,10 +1568,18 @@ def detect_gguf_model_remote(
through to the MLX backend, which then fails opening a non-existent
config.json on the GGUF-only repo. Three attempts with 1s/2s/4s
backoff covers the typical free-runner HF Hub flakiness.
When offline, falls back to the local HF cache so a downloaded
repo is still routed to llama-server (not MLX/Unsloth).
"""
import time
from huggingface_hub import model_info as hf_model_info
if _env_offline():
cached = _detect_gguf_from_hf_cache(repo_id)
if cached is not None:
return cached
last_err: Optional[Exception] = None
for attempt in range(3):
try:
@ -1479,6 +1600,17 @@ def detect_gguf_model_remote(
return None
if attempt < 2:
time.sleep(2**attempt)
# All attempts failed; fall back to local cache for offline users.
cached = _detect_gguf_from_hf_cache(repo_id)
if cached is not None:
logger.warning(
"HF API unreachable for '%s' (%s); using local cache to detect GGUF.",
repo_id,
type(last_err).__name__ if last_err else "unknown",
)
return cached
logger.warning(
f"Could not check GGUF files for '{repo_id}' after 3 attempts: {last_err}"
)
@ -2257,7 +2389,8 @@ class ModelConfig:
f"Auto-detected local LoRA adapter at '{path}' (base: {detected_base})"
)
# Auto-detect LoRA for remote HF models (check repo file listing)
# Auto-detect LoRA for remote HF models. When offline, huggingface_hub
# raises OfflineModeIsEnabled in ~0ms; we fall through to the cache.
if not is_lora and not is_local:
try:
from huggingface_hub import model_info as hf_model_info
@ -2272,6 +2405,16 @@ class ModelConfig:
f"Could not check remote LoRA status for '{identifier}': {e}"
)
# API may have failed; adapter_config.json may still be cached.
if not is_lora:
for snap in _iter_hf_cache_snapshots(identifier):
if (snap / "adapter_config.json").is_file():
is_lora = True
logger.info(
f"Auto-detected cached LoRA adapter: '{identifier}'"
)
break
# Handle LoRA adapters
base_model = None
if is_lora:

View file

@ -44,6 +44,15 @@ from utils.subprocess_compat import (
logger = get_logger(__name__)
def _env_offline() -> bool:
"""True if HF_HUB_OFFLINE or TRANSFORMERS_OFFLINE is set to a truthy value."""
return os.environ.get("HF_HUB_OFFLINE", "").lower() in (
"1",
"true",
"yes",
) or os.environ.get("TRANSFORMERS_OFFLINE", "").lower() in ("1", "true", "yes")
# ---------------------------------------------------------------------------
# Detection
# ---------------------------------------------------------------------------
@ -242,6 +251,11 @@ def _check_tokenizer_config_needs_v5(model_name: str) -> bool:
except Exception as exc:
logger.debug("Could not read %s: %s", local_tc, exc)
# Offline: skip the 10s urllib fetch (fail-open to lower tier).
if _env_offline():
_tokenizer_class_cache[model_name] = False
return False
# --- Fall back to fetching from HuggingFace ----------------------------
import urllib.request
@ -308,6 +322,11 @@ def _check_config_needs_550(model_name: str) -> bool:
except Exception as exc:
logger.debug("Could not read %s: %s", local_cfg, exc)
# Offline: skip the 10s urllib fetch (fail-open to lower tier).
if _env_offline():
_config_needs_550_cache[model_name] = False
return False
# --- Fall back to fetching from HuggingFace ---------------------------
import urllib.request

View file

@ -45,6 +45,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dexie": "^4.3.0",
"fflate": "0.8.3",
"js-yaml": "^4.1.1",
"katex": "^0.16.28",
"lucide-react": "^1.7.0",
@ -9367,6 +9368,12 @@
"node": "^12.20 || >= 14.13"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/figures": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",

View file

@ -53,6 +53,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dexie": "^4.3.0",
"fflate": "0.8.3",
"js-yaml": "^4.1.1",
"katex": "^0.16.28",
"lucide-react": "^1.7.0",

View file

@ -291,7 +291,14 @@ export function AppProvider({ children }: AppProviderProps) {
<TauriWrapper>
{children}
</TauriWrapper>
<Toaster position="top-right" visibleToasts={2} expand={true} />
<Toaster
position="top-right"
visibleToasts={2}
expand={true}
closeButton={true}
// Clear the chat header buttons on the right.
offset={{ top: 12, right: 64 }}
/>
</ThemeProvider>
);
}

View file

@ -12,6 +12,7 @@ import { Route as indexRoute } from "./routes/index";
import { Route as loginRoute } from "./routes/login";
import { Route as onboardingRoute } from "./routes/onboarding";
import { Route as changePasswordRoute } from "./routes/change-password";
import { Route as settingsRoute } from "./routes/settings";
import { Route as studioRoute } from "./routes/studio";
const routeTree = rootRoute.addChildren([
@ -20,6 +21,7 @@ const routeTree = rootRoute.addChildren([
loginRoute,
changePasswordRoute,
gridTestRoute,
settingsRoute,
studioRoute,
chatRoute,
exportRoute,

View file

@ -0,0 +1,20 @@
// 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 { createRoute, redirect } from "@tanstack/react-router";
import { getPostAuthRoute } from "@/features/auth";
import { useSettingsDialogStore } from "@/features/settings";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
// /settings is a deep link to the modal. Open it, then redirect home.
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/settings",
beforeLoad: async () => {
await requireAuth();
useSettingsDialogStore.getState().openDialog();
throw redirect({ to: getPostAuthRoute() });
},
component: () => null,
});

View file

@ -49,6 +49,7 @@ import {
Edit03Icon,
Globe02Icon,
HelpCircleIcon,
Logout01Icon,
Search01Icon,
PowerIcon,
PencilEdit02Icon,
@ -77,6 +78,7 @@ import {
import { useSettingsDialogStore } from "@/features/settings";
import { useEffectiveProfile, UserAvatar } from "@/features/profile";
import { usePlatformStore } from "@/config/env";
import { clearAuthTokens, logout } from "@/features/auth";
import { TOUR_OPEN_EVENT } from "@/features/tour";
import {
deleteTrainingRun,
@ -89,7 +91,7 @@ import {
} from "@/features/training";
import type { TrainingRunSummary } from "@/features/training";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
import { ShutdownDialog } from "@/components/shutdown-dialog";
function getTourId(pathname: string): string | null {
@ -757,6 +759,21 @@ export function AppSidebar() {
<HugeiconsIcon icon={HelpCircleIcon} strokeWidth={1.75} className="size-icon" />
<span>Help</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={async () => {
// Best-effort server-side revocation; ignore network errors
// so the local clear path still runs and the user lands on /login.
try {
await logout();
} catch {
clearAuthTokens();
}
void navigate({ to: "/login" });
}}
>
<HugeiconsIcon icon={Logout01Icon} strokeWidth={1.75} className="size-icon" />
<span>Log out</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShutdownOpen(true)}>
<HugeiconsIcon icon={PowerIcon} strokeWidth={1.75} className="size-icon" />
<span>Shutdown</span>

View file

@ -14,7 +14,7 @@ import {
import { cn } from "@/lib/utils";
import { Trash2Icon } from "lucide-react";
import { useCallback, useState, type ReactNode } from "react";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
interface ModelDeleteActionProps {
ariaLabel: string;

View file

@ -50,7 +50,7 @@ import {
useMemo,
useState,
} from "react";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
import type {
DeletedModelRef,
LoraModelOption,

View file

@ -66,7 +66,6 @@ import {
HeadphonesIcon,
LightbulbIcon,
LightbulbOffIcon,
LoaderIcon,
MicIcon,
MoreHorizontalIcon,
RefreshCwIcon,
@ -81,12 +80,13 @@ import {
type CompositionEvent,
type FC,
type FormEvent,
type KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
export const Thread: FC<{
hideComposer?: boolean;
@ -246,7 +246,6 @@ const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
Run GGUFs, safetensors, vision and audio models
</p>
</div>
<GeneratingSpinner />
{!hideComposer && <ComposerAnimated />}
</div>
</div>
@ -254,21 +253,6 @@ const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
);
};
const GeneratingSpinner: FC = () => {
const status = useChatRuntimeStore((s) => s.generatingStatus);
if (!status) {
return null;
}
return (
<div className="mx-auto flex w-full max-w-(--thread-max-width) items-center justify-center py-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
<span>Generating</span>
</div>
</div>
);
};
const ComposerAnimated: FC<{ disabled?: boolean }> = ({ disabled }) => {
return (
<div className="relative mx-auto min-w-0 w-full max-w-(--thread-max-width)">
@ -305,14 +289,19 @@ const PendingAudioChip: FC = () => {
const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
const { inputProps, isComposing, isComposingRef } = useImeComposerInputHandlers();
const hasPendingAttachments = useAuiState(({ composer }) =>
composer.attachments.some(
(attachment) => attachment.status.type === "running",
),
);
const handleSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
if (disabled || isComposingRef.current) {
if (disabled || isComposingRef.current || hasPendingAttachments) {
event.preventDefault();
}
},
[disabled, isComposingRef],
[disabled, hasPendingAttachments, isComposingRef],
);
const composerContent = (
@ -324,15 +313,18 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
placeholder="Send a message..."
className="aui-composer-input composer-input"
minRows={1}
maxRows={6}
maxRows={12}
autoFocus={!disabled}
disabled={disabled}
aria-label="Message input"
// dir="auto": browser picks LTR/RTL from the first strong char;
// no effect on Latin / CJK / Devanagari.
dir="auto"
{...inputProps}
/>
<ComposerAction
disabled={disabled || isComposing}
blockSend={() => isComposingRef.current}
disabled={disabled || isComposing || hasPendingAttachments}
blockSend={() => isComposingRef.current || hasPendingAttachments}
/>
</>
);
@ -362,16 +354,58 @@ function isNativeComposing(event: Event) {
return "isComposing" in event && (event as InputEvent).isComposing === true;
}
// Fallback timeout for stuck IME composition. When Chrome on Windows talks
// to a WSL-hosted Studio (issue #5546), `compositionend` never fires after
// the candidate is committed, so `composingRef` stays true and Send stays
// disabled. Every compositionupdate / non-composing input resets the timer;
// only a true gap-after-commit lets it fire. 2500ms is well above a normal
// candidate-window pause but short enough to recover before the user
// notices the Send button is stuck.
const IME_STUCK_TIMEOUT_MS = 2500;
function useImeComposerInputHandlers() {
const aui = useAui();
const composingRef = useRef(false);
const [isComposing, setIsComposing] = useState(false);
const stuckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const setCompositionState = useCallback((next: boolean) => {
composingRef.current = next;
setIsComposing(next);
const clearStuckTimer = useCallback(() => {
if (stuckTimerRef.current) {
clearTimeout(stuckTimerRef.current);
stuckTimerRef.current = null;
}
}, []);
const setCompositionState = useCallback(
(next: boolean) => {
composingRef.current = next;
setIsComposing(next);
clearStuckTimer();
if (next) {
stuckTimerRef.current = setTimeout(() => {
stuckTimerRef.current = null;
composingRef.current = false;
setIsComposing(false);
}, IME_STUCK_TIMEOUT_MS);
}
},
[clearStuckTimer],
);
const refreshStuckTimer = useCallback(() => {
if (!composingRef.current) {
return;
}
clearStuckTimer();
stuckTimerRef.current = setTimeout(() => {
stuckTimerRef.current = null;
composingRef.current = false;
setIsComposing(false);
}, IME_STUCK_TIMEOUT_MS);
}, [clearStuckTimer]);
useEffect(() => clearStuckTimer, [clearStuckTimer]);
const setComposerText = useCallback(
(value: string) => {
const composer = aui.composer();
@ -389,6 +423,10 @@ function useImeComposerInputHandlers() {
setCompositionState(true);
}, [setCompositionState]);
const onCompositionUpdate = useCallback(() => {
refreshStuckTimer();
}, [refreshStuckTimer]);
const onCompositionEnd = useCallback(
(e: CompositionEvent<HTMLTextAreaElement>) => {
setCompositionState(false);
@ -405,11 +443,31 @@ function useImeComposerInputHandlers() {
[setComposerText, setCompositionState],
);
// If the watchdog cleared the composing flags during a long candidate-window
// pause, a subsequent IME keypress (browser-side isComposing=true / IME
// keyCode 229) would otherwise reach handleSubmit with composingRef=false
// and submit the preedit text. Re-arm composingRef synchronously from the
// native event so the form-submit gate keeps blocking until compositionend.
// Re-arm the watchdog at the same time — otherwise the WSL+Chrome path
// this PR targets (no compositionend, no follow-up input event) would
// leave composingRef pinned true indefinitely and Send blocked again.
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing || e.keyCode === 229) {
composingRef.current = true;
refreshStuckTimer();
}
},
[refreshStuckTimer],
);
return {
inputProps: {
onCompositionStart,
onCompositionUpdate,
onCompositionEnd,
onChange,
onKeyDown,
},
isComposing,
isComposingRef: composingRef,
@ -555,12 +613,12 @@ const ReasoningToggle: FC = () => {
type="button"
disabled={disabled}
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
disabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningVisualEnabled
? "bg-primary/10 text-primary hover:bg-primary/20"
: "text-muted-foreground hover:bg-muted-foreground/15",
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={`Reasoning effort: ${reasoningEffort}`}
>
@ -673,12 +731,12 @@ const PreserveThinkingToggle: FC = () => {
disabled={disabled}
onClick={() => setPreserveThinking(!preserveThinking)}
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
disabled
? "cursor-not-allowed opacity-40"
: preserveThinking
? "bg-primary/10 text-primary hover:bg-primary/20"
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={
preserveThinking ? "Disable preserve think" : "Enable preserve think"
@ -845,7 +903,7 @@ const ComposerAction: FC<{ disabled?: boolean; blockSend?: () => boolean }> = ({
}) => {
return (
<div className="aui-composer-action-wrapper composer-action-wrapper">
<div className="flex items-center gap-1">
<div className="flex items-center gap-0.5">
<ComposerAddAttachment />
<ComposerAudioUpload />
<ReasoningToggle />
@ -1161,6 +1219,8 @@ const EditComposer: FC = () => {
<ComposerPrimitive.Input
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm font-[450] outline-none"
autoFocus={true}
// See main composer above for the dir="auto" rationale.
dir="auto"
{...inputProps}
/>
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">

View file

@ -0,0 +1,91 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useRef, useState } from "react";
interface CopyableErrorChipProps {
message: string;
className?: string;
}
export function CopyableErrorChip({
message,
className,
}: CopyableErrorChipProps) {
const [copied, setCopied] = useState(false);
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Clear any pending reset on unmount to avoid a setState on an
// unmounted component.
useEffect(() => () => {
if (resetTimer.current) clearTimeout(resetTimer.current);
}, []);
const handleCopy = async () => {
if (await copyToClipboard(message)) {
setCopied(true);
if (resetTimer.current) clearTimeout(resetTimer.current);
resetTimer.current = setTimeout(() => {
setCopied(false);
resetTimer.current = null;
}, 1800);
}
};
return (
<Popover>
<PopoverTrigger asChild={true}>
{/* No aria-label override: the visible message text is the
button's accessible name, so screen readers announce the
full (untruncated) error. Truncation here is purely visual. */}
<button
type="button"
className={cn(
"flex max-w-[28rem] min-w-0 cursor-pointer items-center rounded-md text-left text-xs text-destructive transition-colors hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
<span className="min-w-0 flex-1 truncate">{message}</span>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
className="w-[min(36rem,calc(100vw-1rem))] gap-2"
>
<div className="flex items-start justify-between gap-2">
<span className="text-xs font-medium text-destructive">Error</span>
<button
type="button"
onClick={handleCopy}
aria-label={copied ? "Copied" : "Copy error message"}
className={cn(
"inline-flex items-center gap-1 rounded-md border border-border/60 px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
copied && "border-emerald-500/40 text-emerald-600 dark:text-emerald-500",
)}
>
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy01Icon}
className="size-3.5"
/>
{copied ? "Copied" : "Copy"}
</button>
</div>
<p className="max-h-64 overflow-y-auto select-text whitespace-pre-wrap break-words text-xs text-destructive">
{message}
</p>
</PopoverContent>
</Popover>
);
}

View file

@ -13,11 +13,13 @@ import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
// Use resolvedTheme so sonner's data-sonner-theme always matches the class
// next-themes puts on <html>; sonner-side "system" resolution can drift.
const { resolvedTheme } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
theme={(resolvedTheme as ToasterProps["theme"]) ?? "light"}
className="toaster group"
duration={5000}
icons={{
@ -63,13 +65,21 @@ const Toaster = ({ ...props }: ToasterProps) => {
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
// Pin close button to the top-right corner inside the toast.
// Overrides sonner's default left placement and outside-corner
// translate; top offset is set via a rule in index.css since sonner
// hardcodes `top: 0` (not a CSS variable).
"--toast-close-button-start": "unset",
"--toast-close-button-end": "8px",
"--toast-close-button-transform": "none",
} as React.CSSProperties
}
// No swipe gestures; keeps toast text selectable.
swipeDirections={[]}
toastOptions={{
classNames: {
toast: "cn-toast",
description: "!text-muted-foreground",
closeButton: "!top-3 !right-3 !translate-y-0",
},
}}
{...props}

View file

@ -2,12 +2,17 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { cn } from "@/lib/utils"
import { HugeiconsIcon } from "@hugeicons/react"
import { Loading03Icon } from "@hugeicons/core-free-icons"
function Spinner({ className }: { className?: string }) {
return (
<HugeiconsIcon icon={Loading03Icon} strokeWidth={2} role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} />
<span
role="status"
aria-label="Loading"
className={cn(
"inline-block size-4 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent",
className,
)}
/>
)
}

View file

@ -7,6 +7,7 @@ import {
getAuthToken,
getRefreshToken,
mustChangePassword,
setMustChangePassword,
storeAuthTokens,
} from "./session";
@ -93,34 +94,45 @@ async function retryWithTauriAutoAuth(
return null;
}
// Singleflight: the backend consumes the refresh token atomically, so
// concurrent callers must share one in-flight promise (loser would 401).
let refreshInflight: Promise<boolean> | null = null;
// Bumped by logout(); a refresh that resolves after logout drops its
// new tokens instead of silently re-auth-ing the SPA.
let logoutGeneration = 0;
export async function refreshSession(): Promise<boolean> {
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
try {
const response = await fetchWithTauriNetworkRetry(
apiUrl("/api/auth/refresh"),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
},
);
if (!response.ok) {
clearAuthTokens();
if (refreshInflight) return refreshInflight;
const startGeneration = logoutGeneration;
refreshInflight = (async () => {
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
try {
const response = await fetchWithTauriNetworkRetry(
apiUrl("/api/auth/refresh"),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
},
);
if (!response.ok) {
clearAuthTokens();
return false;
}
const payload = (await response.json()) as RefreshResponse;
if (startGeneration !== logoutGeneration) return false;
storeAuthTokens(payload.access_token, payload.refresh_token);
setMustChangePassword(payload.must_change_password ?? false);
return true;
} catch {
return false;
}
const payload = (await response.json()) as RefreshResponse;
storeAuthTokens(
payload.access_token,
payload.refresh_token,
payload.must_change_password,
);
return true;
} catch {
return false;
})();
try {
return await refreshInflight;
} finally {
refreshInflight = null;
}
}
@ -179,6 +191,32 @@ export async function authFetch(
return retryWithCurrentToken(resolvedInput, init);
}
export function logout(): void {
clearAuthTokens();
async function postLogout(accessToken: string | null): Promise<Response | null> {
try {
return await fetchWithTauriNetworkRetry(apiUrl("/api/auth/logout"), {
method: "POST",
headers: accessToken
? { Authorization: `Bearer ${accessToken}` }
: undefined,
});
} catch {
return null;
}
}
export async function logout(): Promise<void> {
// Server-side revoke. If the access token is expired the 401 fires
// BEFORE revoke runs; rotate via the refresh token and retry so the
// refresh family is actually revoked. Generation bump in finally
// invalidates any in-flight refresh from before this call.
try {
let response = await postLogout(getAuthToken());
if (response && response.status === 401 && getRefreshToken()) {
const refreshed = await refreshSession();
if (refreshed) response = await postLogout(getAuthToken());
}
} finally {
logoutGeneration += 1;
clearAuthTokens();
}
}

View file

@ -79,6 +79,7 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
const navigate = useNavigate();
const isLoginMode = mode === "login";
const [showPassword, setShowPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const username = HIDDEN_LOGIN_USERNAME;
const [password, setPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
@ -182,6 +183,10 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
const switchLinkTo = "/login";
const switchLinkText = "Back to login";
const currentPassword = password || window.__UNSLOTH_BOOTSTRAP__?.password || "";
// On first boot the backend injects __UNSLOTH_BOOTSTRAP__ and we silently
// reuse that password; the Current password input is only rendered for the
// admin-forced must_change_password path where no bootstrap is available.
const hasBootstrapPassword = Boolean(window.__UNSLOTH_BOOTSTRAP__?.password);
const invalidChangePasswordForm =
!isLoginMode &&
(newPassword.length < 8 || newPassword !== confirmPassword || currentPassword === newPassword);
@ -237,7 +242,6 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
storeAuthTokens(
bootstrapToken.access_token,
bootstrapToken.refresh_token,
bootstrapToken.must_change_password,
);
setMustChangePassword(bootstrapToken.must_change_password);
accessToken = bootstrapToken.access_token;
@ -274,11 +278,7 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
} else {
setMustChangePassword(token.must_change_password);
}
storeAuthTokens(
token.access_token,
token.refresh_token,
token.must_change_password,
);
storeAuthTokens(token.access_token, token.refresh_token);
navigate({ to: getPostAuthRoute() });
} catch (err: unknown) {
let msg = err instanceof Error ? err.message : "Auth failed.";
@ -341,12 +341,42 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
{!isLoginMode && (
<>
{!hasBootstrapPassword && (
<div className="space-y-2">
<Label htmlFor="current-password">Current password</Label>
<div className="relative">
<Input
id="current-password"
type={showPassword ? "text" : "password"}
className="pr-10"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
required
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:bg-transparent"
onClick={() => setShowPassword((prev) => !prev)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="new-password">New password</Label>
<div className="relative">
<Input
id="new-password"
type={showPassword ? "text" : "password"}
type={showNewPassword ? "text" : "password"}
className="pr-10"
autoComplete="new-password"
value={newPassword}
@ -359,9 +389,9 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:bg-transparent"
onClick={() => setShowPassword((prev) => !prev)}
onClick={() => setShowNewPassword((prev) => !prev)}
>
{showPassword ? (
{showNewPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />

View file

@ -3,8 +3,9 @@
export { LoginPage } from "./login-page";
export { ChangePasswordPage } from "./change-password-page";
export { authFetch, refreshSession } from "./api";
export { authFetch, logout, refreshSession } from "./api";
export {
clearAuthTokens,
getAuthToken,
getPostAuthRoute,
hasAuthToken,

View file

@ -38,12 +38,13 @@ export function getRefreshToken(): string | null {
export function storeAuthTokens(
accessToken: string,
refreshToken: string,
mustChangePassword = false,
): void {
// Callers set must_change_password via setMustChangePassword(). Routing it
// through here would let CodeQL trace the boolean to localStorage and flag
// the (deliberate) JWT writes as sensitive-info storage.
if (!canUseStorage()) return;
localStorage.setItem(AUTH_TOKEN_KEY, accessToken);
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, refreshToken);
localStorage.setItem(AUTH_MUST_CHANGE_PASSWORD_KEY, String(mustChangePassword));
}
export function clearAuthTokens(): void {
@ -53,14 +54,22 @@ export function clearAuthTokens(): void {
localStorage.removeItem(AUTH_MUST_CHANGE_PASSWORD_KEY);
}
// Encode the flag as key presence (literal "1" or absence) so localStorage
// receives a constant, not a derived boolean. Breaks the CodeQL data flow
// from TokenResponse.must_change_password into localStorage.setItem; the
// stored value is a route hint (/change-password vs /chat), not a secret.
export function mustChangePassword(): boolean {
if (!canUseStorage()) return false;
return localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY) === "true";
return localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY) !== null;
}
export function setMustChangePassword(required: boolean): void {
if (!canUseStorage()) return;
localStorage.setItem(AUTH_MUST_CHANGE_PASSWORD_KEY, String(required));
if (required) {
localStorage.setItem(AUTH_MUST_CHANGE_PASSWORD_KEY, "1");
} else {
localStorage.removeItem(AUTH_MUST_CHANGE_PASSWORD_KEY);
}
}
export function isOnboardingDone(): boolean {

View file

@ -6,6 +6,7 @@ import {
hasAuthToken,
hasRefreshToken,
mustChangePassword,
setMustChangePassword,
storeAuthTokens,
} from "./session";
import { refreshSession } from "./api";
@ -72,7 +73,8 @@ async function doTauriAutoAuth(options: TauriAutoAuthOptions): Promise<boolean>
try {
const { invoke } = await import("@tauri-apps/api/core");
const tokens = await invoke<DesktopAuthResponse>("desktop_auth");
storeAuthTokens(tokens.access_token, tokens.refresh_token, false);
storeAuthTokens(tokens.access_token, tokens.refresh_token);
setMustChangePassword(false);
clearTauriAuthFailure();
return true;
} catch (error) {

View file

@ -3,7 +3,7 @@
import type { ChatModelAdapter } from "@assistant-ui/react";
import type { MessageTiming, ToolCallMessagePart } from "@assistant-ui/core";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
import { getAuthToken } from "@/features/auth/session";
import { apiUrl } from "@/lib/api-base";
import {
@ -16,7 +16,10 @@ import {
validateModel,
} from "./chat-api";
import { pickFriendlyContainerName } from "../lib/friendly-names";
import { createOpenAIContainer } from "./openai-containers";
import {
createOpenAIContainer,
listOpenAIContainers,
} from "./openai-containers";
import {
encryptProviderApiKey,
isProviderKeyRotationError,
@ -31,6 +34,7 @@ import {
isCustomProviderType,
loadExternalProviders,
parseExternalModelId,
providerTypeSupportsVision,
supportsProviderPromptCaching,
toExternalBackendProviderType,
} from "../external-providers";
@ -46,6 +50,7 @@ import {
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import { isMultimodalResponse } from "../types/api";
import type { ChatModelSummary } from "../types/runtime";
import { getImageInputUnavailableReason } from "../utils/image-input-support";
import {
hasClosedThinkTag,
parseAssistantContent,
@ -779,6 +784,37 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
}
const imageBase64 = findLatestUserImageBase64(messages);
const audioBase64 = findLatestUserAudioBase64(messages);
// Block when ANY image is in the outbound payload (current or
// prior turns) and the loaded model can't process images. Keeps
// the gate simple: once a chat contains an image, a non-vision
// model can't respond — user starts a new chat to switch models.
if (imageBase64) {
const activeModel = runtime.models.find(
(m) => m.id === params.checkpoint,
);
const imageGateReason = getImageInputUnavailableReason({
activeModel,
isExternalModel: isExternalRequest,
externalSupportsVision: providerTypeSupportsVision(
externalProvider?.providerType,
),
externalModelLabel: externalSelection?.modelId ?? null,
loadedIsMultimodal: runtime.loadedIsMultimodal,
modelLoaded: !!params.checkpoint && !runtime.modelLoading,
});
if (imageGateReason) {
toast.error(imageGateReason);
// Flip the per-thread running flag on→off so the compare-mode
// waitForRunEnd resolves instead of hanging. This gate fires
// before the streaming path's setThreadRunning(true), so the
// wait promise would otherwise never settle.
const gatedThreadKey = resolvedThreadId || "__default";
runtime.setThreadRunning(gatedThreadKey, true);
runtime.setThreadRunning(gatedThreadKey, false);
throw new Error(imageGateReason);
}
}
// Clear pending audio from store after extracting (consumed on send)
if (audioBase64) {
const audioName = runtime.pendingAudioName;
@ -990,9 +1026,11 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// container_id (if any) so subsequent turns in the same
// thread reference the existing container instead of
// auto-creating a fresh one. Empty string / undefined →
// backend falls back to container_auto. Anthropic doesn't
// use this (server-side per-turn container).
// backend falls back to container_auto. Anthropic uses
// the parallel `anthropicCodeExecContainerId` field below
// (sent as `container` on /v1/messages).
let openaiCodeExecContainerId: string | null = null;
let anthropicCodeExecContainerId: string | null = null;
const codeExecEnabledForThisTurn =
codeToolsEnabled &&
providerSupportsBuiltinCodeExecution(
@ -1005,8 +1043,46 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
const thread = await db.threads.get(resolvedThreadId);
openaiCodeExecContainerId =
thread?.openaiCodeExecContainerId ?? null;
anthropicCodeExecContainerId =
thread?.anthropicCodeExecContainerId ?? null;
} catch {
openaiCodeExecContainerId = null;
anthropicCodeExecContainerId = null;
}
// Pre-send container validation (OpenAI only). The list
// endpoint already filters status==="expired" server-side
// (studio/backend/routes/inference.py — list_openai_containers),
// so membership in this set means "OpenAI will accept it
// as container_reference". A stale id silently dropped here
// falls through to the inheritance + lazy-create logic
// below, so the user never sees "Container is expired" in
// the chat thread. On list-call failure we leave
// activeContainerIds null and skip validation — the
// backend's transparent retry path is the safety net for
// that case.
let activeContainerIds: Set<string> | null = null;
if (externalProvider.providerType === "openai") {
try {
const list = await listOpenAIContainers({
apiKey: externalApiKey,
baseUrl: externalProvider.baseUrl || null,
});
activeContainerIds = new Set(list.map((c) => c.id));
} catch {
activeContainerIds = null;
}
if (
activeContainerIds &&
openaiCodeExecContainerId &&
!activeContainerIds.has(openaiCodeExecContainerId)
) {
void db.threads
.update(resolvedThreadId, {
openaiCodeExecContainerId: null,
})
.catch(() => {});
openaiCodeExecContainerId = null;
}
}
// Cross-thread inheritance: when the active thread has
// no container yet, default to the one most recently
@ -1028,15 +1104,27 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
.toArray();
for (const t of others) {
if (t.id === resolvedThreadId) continue;
if (t.openaiCodeExecContainerId) {
openaiCodeExecContainerId = t.openaiCodeExecContainerId;
if (!t.openaiCodeExecContainerId) continue;
// Skip inherited ids that are not in the active
// container set — they would 400 on send. Also
// null them on the source thread so the next
// inheritance pass doesn't re-pick the same dead id.
if (
activeContainerIds &&
!activeContainerIds.has(t.openaiCodeExecContainerId)
) {
void db.threads
.update(resolvedThreadId, {
openaiCodeExecContainerId,
})
.update(t.id, { openaiCodeExecContainerId: null })
.catch(() => {});
break;
continue;
}
openaiCodeExecContainerId = t.openaiCodeExecContainerId;
void db.threads
.update(resolvedThreadId, {
openaiCodeExecContainerId,
})
.catch(() => {});
break;
}
} catch {
/* fall through to lazy-create below */
@ -1172,6 +1260,12 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
openai_code_exec_container_id: openaiCodeExecContainerId,
}
: {}),
...(anthropicCodeExecContainerId
? {
anthropic_code_exec_container_id:
anthropicCodeExecContainerId,
}
: {}),
...(supportsProviderPromptCaching(externalProvider.providerType)
? {
enable_prompt_caching:
@ -1265,19 +1359,45 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
| string
| undefined;
if (newContainerId && resolvedThreadId) {
void db.threads
.update(resolvedThreadId, {
openaiCodeExecContainerId: newContainerId,
})
.catch(() => {});
const field =
externalProvider?.providerType === "anthropic"
? "anthropicCodeExecContainerId"
: "openaiCodeExecContainerId";
// On the first turn of a brand-new thread the row
// may not be in Dexie yet when this SSE event
// fires — db.threads.update silently affects 0
// rows, the next turn re-reads null, and Anthropic
// auto-creates a fresh container. Retry briefly so
// assistant-ui's own DexieAdapter.initialize lands
// the row first (with the correct modelType for
// base / lora / compare contexts) and our update
// sticks on a subsequent attempt.
try {
for (let attempt = 0; attempt < 10; attempt++) {
const affected = await db.threads.update(
resolvedThreadId,
{ [field]: newContainerId },
);
if (affected > 0) break;
await new Promise((resolve) =>
setTimeout(resolve, 50),
);
}
} catch {
/* best-effort: container reuse is an optimization */
}
}
continue;
}
if (toolEvent.type === "container_invalidated") {
if (resolvedThreadId) {
const field =
externalProvider?.providerType === "anthropic"
? "anthropicCodeExecContainerId"
: "openaiCodeExecContainerId";
void db.threads
.update(resolvedThreadId, {
openaiCodeExecContainerId: null,
[field]: null,
})
.catch(() => {});
}

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch } from "@/features/auth";
import { formatFastApiDetail } from "@/lib/format-fastapi-error";
import type {
AudioGenerationResponse,
GgufVariantsResponse,
@ -17,21 +18,12 @@ import type {
} from "../types/api";
function parseErrorText(status: number, body: unknown): string {
if (
body &&
typeof body === "object" &&
"detail" in body &&
typeof body.detail === "string"
) {
return body.detail;
}
if (
body &&
typeof body === "object" &&
"message" in body &&
typeof body.message === "string"
) {
return body.message;
if (body && typeof body === "object") {
const detail = (body as { detail?: unknown }).detail;
const formatted = formatFastApiDetail(detail);
if (formatted) return formatted;
const message = (body as { message?: unknown }).message;
if (typeof message === "string" && message) return message;
}
return `Request failed (${status})`;
}

View file

@ -10,6 +10,7 @@
*/
import { authFetch } from "@/features/auth";
import { readFastApiError } from "@/lib/format-fastapi-error";
import { encryptProviderApiKey } from "./providers-api";
export interface OpenAIContainerSummary {
@ -42,13 +43,7 @@ function fromRaw(raw: RawSummary): OpenAIContainerSummary {
}
async function parseError(response: Response): Promise<string> {
try {
const body = (await response.json()) as { detail?: string };
if (body && typeof body.detail === "string") return body.detail;
} catch {
/* fall through */
}
return `HTTP ${response.status}`;
return readFastApiError(response, "HTTP");
}
interface AuthInputs {

View file

@ -3,6 +3,7 @@
import forge from "node-forge";
import { authFetch } from "@/features/auth";
import { formatFastApiDetail } from "@/lib/format-fastapi-error";
export interface ProviderRegistryEntry {
provider_type: string;
@ -40,21 +41,12 @@ export interface ProviderTestResult {
}
function parseErrorText(status: number, body: unknown): string {
if (
body &&
typeof body === "object" &&
"detail" in body &&
typeof body.detail === "string"
) {
return body.detail;
}
if (
body &&
typeof body === "object" &&
"message" in body &&
typeof body.message === "string"
) {
return body.message;
if (body && typeof body === "object") {
const detail = (body as { detail?: unknown }).detail;
const formatted = formatFastApiDetail(detail);
if (formatted) return formatted;
const message = (body as { message?: unknown }).message;
if (typeof message === "string" && message) return message;
}
return `Request failed (${status})`;
}

View file

@ -34,10 +34,11 @@ import {
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
import type { ChatSearch } from "@/app/routes/chat";
import { listLocalModels } from "./api/chat-api";
import { ChatSettingsPanel } from "./chat-settings-sheet";
import { CopyableErrorChip } from "@/components/ui/copyable-error-chip";
import { ContextUsageBar } from "./components/context-usage-bar";
import { ModelLoadInlineStatus } from "./components/model-load-status";
import { db } from "./db";
@ -1375,12 +1376,11 @@ export function ChatPage(): ReactElement {
) : null}
{!loadingModel && modelsError ? (
<div
className="relative top-0.5 max-w-[28rem] truncate pl-0.5 text-xs text-destructive"
title={modelsError}
className="relative top-0.5 pl-0.5"
role="status"
aria-live="polite"
>
{modelsError}
<CopyableErrorChip message={modelsError} />
</div>
) : null}
</div>

View file

@ -135,6 +135,9 @@ function parseManualModelIds(text: string): string[] {
return out;
}
// Remote providers safe for manual model IDs (openrouter drops unused params).
const MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES = new Set<string>(["openrouter"]);
function pruneProviderModelIds(providerType: string, modelIds: string[]): string[] {
if (providerType === "anthropic") {
return modelIds.filter((id) => !ANTHROPIC_DATED_SNAPSHOT_SUFFIX.test(id));
@ -330,6 +333,10 @@ export function ChatProvidersSettings({
updatedAt,
};
});
// Don't wipe localStorage providers when the server has no rows.
if (syncedProviders.length === 0 && providersRef.current.length > 0) {
return;
}
onProvidersChange(syncedProviders);
} catch (error) {
const message =
@ -501,19 +508,28 @@ export function ChatProvidersSettings({
return;
}
const curated = selectedRegistryEntry?.model_list_mode === "curated";
const manualModels = isCustomProvider || curated;
const manualOnly = isCustomProvider || curated;
const remoteAllowsManual =
MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(providerType);
const manualIds = parseManualModelIds(manualModelIds);
const allowManual = manualOnly || remoteAllowsManual;
const modelsToSave = pruneProviderModelIds(
providerType,
manualModels
allowManual
? [
...new Set([
...selectedModelIds,
...parseManualModelIds(manualModelIds),
...manualIds,
]),
]
: [...selectedModelIds],
);
if (manualModels) {
if (manualOnly) {
if (modelsToSave.length === 0) {
toast.error("Add at least one model ID.");
return;
}
} else if (remoteAllowsManual && manualIds.length > 0) {
if (modelsToSave.length === 0) {
toast.error("Add at least one model ID.");
return;
@ -553,7 +569,7 @@ export function ChatProvidersSettings({
name: created.display_name,
baseUrl: created.base_url ?? "",
models: modelsToSave,
availableModels: manualModels
availableModels: manualOnly
? []
: pruneProviderModelIds(providerType, availableModels),
isReasoningModel: supportsProviderReasoningToggle(uiProviderType)
@ -597,19 +613,29 @@ export function ChatProvidersSettings({
}
const entry = registryByType.get(existing.providerType);
const curated = entry?.model_list_mode === "curated";
const manualModels = isEditingCustomProvider || curated;
const manualOnly = isEditingCustomProvider || curated;
const remoteAllowsManual = MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(
existing.providerType,
);
const manualIds = parseManualModelIds(manualModelIds);
const allowManual = manualOnly || remoteAllowsManual;
const modelsToSave = pruneProviderModelIds(
existing.providerType,
manualModels
allowManual
? [
...new Set([
...selectedModelIds,
...parseManualModelIds(manualModelIds),
...manualIds,
]),
]
: [...selectedModelIds],
);
if (manualModels) {
if (manualOnly) {
if (modelsToSave.length === 0) {
toast.error("Add at least one model ID.");
return;
}
} else if (remoteAllowsManual && manualIds.length > 0) {
if (modelsToSave.length === 0) {
toast.error("Add at least one model ID.");
return;
@ -655,7 +681,7 @@ export function ChatProvidersSettings({
name: updated.display_name,
baseUrl: updated.base_url ?? "",
models: modelsToSave,
availableModels: manualModels
availableModels: manualOnly
? []
: pruneProviderModelIds(existing.providerType, availableModels),
isReasoningModel: supportsProviderReasoningToggle(
@ -1184,71 +1210,99 @@ export function ChatProvidersSettings({
/>
</div>
</div>
) : availableModels.length === 0 ? null : (
) : availableModels.length === 0 &&
!MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(providerType) ? null : (
<div className="space-y-3 px-4 py-4">
<div className="grid grid-cols-[112px_minmax(220px,330px)_auto] items-center gap-3 max-sm:grid-cols-1">
<span className="whitespace-nowrap text-xs font-medium text-muted-foreground">
{availableModelsLabel}
</span>
<Input
id={`provider-model-search-${modelsPanelKey}`}
type="search"
value={modelSearchQuery}
onChange={(event) =>
setModelSearchQuery(event.target.value)
}
placeholder="Search"
aria-label="Search models"
className={modelSearchInputClassName}
/>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs font-medium text-foreground/80 hover:bg-muted/45"
onClick={selectAllModels}
>
Select all
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs font-medium text-foreground/80 hover:bg-muted/45"
onClick={clearModelSelection}
>
Clear
</Button>
</div>
</div>
<ul className="max-h-56 overflow-y-auto rounded-[8px] border border-border/70 bg-background/50">
{filteredAvailableModels.length === 0 ? (
<li className="px-3 py-3 text-xs text-muted-foreground">
No matching models
</li>
) : (
filteredAvailableModels.map((model, index) => (
<li
key={model}
className="flex cursor-pointer items-center gap-2.5 border-border/60 border-b px-3 py-2 last:border-b-0 hover:bg-muted/35"
onClick={() => toggleModel(model)}
>
<Checkbox
id={`provider-model-remote-${modelsPanelKey}-${index}`}
checked={selectedModelIds.includes(model)}
onCheckedChange={() => toggleModel(model)}
onClick={(event) => event.stopPropagation()}
/>
<span
className="min-w-0 break-all text-sm leading-tight"
{availableModels.length === 0 ? null : (
<>
<div className="grid grid-cols-[112px_minmax(220px,330px)_auto] items-center gap-3 max-sm:grid-cols-1">
<span className="whitespace-nowrap text-xs font-medium text-muted-foreground">
{availableModelsLabel}
</span>
<Input
id={`provider-model-search-${modelsPanelKey}`}
type="search"
value={modelSearchQuery}
onChange={(event) =>
setModelSearchQuery(event.target.value)
}
placeholder="Search"
aria-label="Search models"
className={modelSearchInputClassName}
/>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs font-medium text-foreground/80 hover:bg-muted/45"
onClick={selectAllModels}
>
{model}
</span>
</li>
))
)}
</ul>
Select all
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs font-medium text-foreground/80 hover:bg-muted/45"
onClick={clearModelSelection}
>
Clear
</Button>
</div>
</div>
<ul className="max-h-56 overflow-y-auto rounded-[8px] border border-border/70 bg-background/50">
{filteredAvailableModels.length === 0 ? (
<li className="px-3 py-3 text-xs text-muted-foreground">
No matching models
</li>
) : (
filteredAvailableModels.map((model, index) => (
<li
key={model}
className="flex cursor-pointer items-center gap-2.5 border-border/60 border-b px-3 py-2 last:border-b-0 hover:bg-muted/35"
onClick={() => toggleModel(model)}
>
<Checkbox
id={`provider-model-remote-${modelsPanelKey}-${index}`}
checked={selectedModelIds.includes(model)}
onCheckedChange={() => toggleModel(model)}
onClick={(event) => event.stopPropagation()}
/>
<span
className="min-w-0 break-all text-sm leading-tight"
>
{model}
</span>
</li>
))
)}
</ul>
</>
)}
{/* Manual IDs allowed for openrouter only. */}
{MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(providerType) ? (
<div className="space-y-2">
<Label
htmlFor="provider-manual-models"
className="text-sm font-medium"
>
{availableModels.length === 0
? "Or enter model IDs manually (one per line or comma-separated)"
: "Additional model IDs (one per line or comma-separated)"}
</Label>
<Textarea
id="provider-manual-models"
value={manualModelIds}
onChange={(event) =>
setManualModelIds(event.target.value)
}
placeholder={"model-id-1\nmodel-id-2"}
rows={4}
className="min-h-[80px] resize-y font-mono text-sm"
/>
</div>
) : null}
</div>
)}
</motion.div>

View file

@ -62,7 +62,7 @@ import { Tooltip as TooltipPrimitive } from "radix-ui";
import { ChevronDown } from "lucide-react";
import { Fragment, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import {
type ExternalProviderConfig,
@ -979,26 +979,25 @@ export function ChatSettingsPanel({
</Select>
</div>
</div>
{!currentModelIsMultimodal && (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Speculative Decoding
</span>
<InfoHint>
N-gram speculation; faster generation with negligible
VRAM overhead. Text-only models.
</InfoHint>
</div>
<Switch
className="panel-switch shrink-0"
checked={speculativeType != null}
onCheckedChange={(checked) => {
setSpeculativeType(checked ? "default" : null);
}}
/>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Speculative Decoding
</span>
<InfoHint>
Faster generation with 0% accuracy hit.
</InfoHint>
</div>
)}
<Switch
className="panel-switch shrink-0"
checked={
speculativeType !== "off" && speculativeType != null
}
onCheckedChange={(checked) => {
setSpeculativeType(checked ? "default" : "off");
}}
/>
</div>
</>
)}
{!isGguf && params.checkpoint && (

View file

@ -56,7 +56,7 @@ export function ModelLoadDescription({
return (
<div className="relative flex min-h-12 w-full items-stretch gap-2">
<div className="flex h-full shrink-0 items-center self-center">
<Spinner className="size-4 text-foreground" />
<Spinner className="size-3.5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 pr-5">
{title ? <p className="text-foreground leading-5 font-semibold">{title}</p> : null}

View file

@ -49,6 +49,30 @@ export function supportsProviderReasoningToggle(
);
}
// Known text-only providers on their main chat endpoint.
const NON_VISION_PROVIDER_TYPES = new Set<string>([
"cohere",
"deepseek",
"mistral",
]);
// Providers whose vision-tier model selection accepts images.
const VISION_CAPABLE_PROVIDER_TYPES = new Set<string>([
"openai",
"anthropic",
"gemini",
"openrouter",
]);
// false = known text-only, true = known vision, null = unknown (default-allow).
export function providerTypeSupportsVision(
providerType: string | null | undefined,
): boolean | null {
if (providerType == null) return null;
if (NON_VISION_PROVIDER_TYPES.has(providerType)) return false;
if (VISION_CAPABLE_PROVIDER_TYPES.has(providerType)) return true;
return null;
}
export const CUSTOM_BACKEND_PROVIDER_TYPE = "openai";
export const LEGACY_CUSTOM_PROVIDER_TYPE = "custom";

View file

@ -2,7 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { createElement, useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
import { consumeNativePathToken } from "@/features/native-intents/api";
import {
notifyNative,
@ -729,7 +729,6 @@ export function useChatModelRuntime() {
cancelLoading,
),
duration: Infinity,
closeButton: false,
classNames: MODEL_LOAD_TOAST_CLASSNAMES,
onDismiss: (dismissedToast) => {
if (loadToastIdRef.current !== dismissedToast.id) {
@ -852,7 +851,6 @@ export function useChatModelRuntime() {
cancelLoading,
),
duration: Infinity,
closeButton: false,
classNames: MODEL_LOAD_TOAST_CLASSNAMES,
onDismiss: (dismissedToast) => {
if (loadToastIdRef.current !== dismissedToast.id) return;
@ -892,7 +890,6 @@ export function useChatModelRuntime() {
cancelLoading,
),
duration: Infinity,
closeButton: false,
classNames: MODEL_LOAD_TOAST_CLASSNAMES,
onDismiss: (dismissedToast) => {
if (loadToastIdRef.current !== dismissedToast.id) return;
@ -955,7 +952,6 @@ export function useChatModelRuntime() {
cancelLoading,
),
duration: Infinity,
closeButton: false,
classNames: MODEL_LOAD_TOAST_CLASSNAMES,
onDismiss: (dismissedToast) => {
if (loadToastIdRef.current !== dismissedToast.id) return;
@ -987,8 +983,7 @@ export function useChatModelRuntime() {
toast.success(`${displayName} loaded`, {
id: toastId,
description: undefined,
closeButton: false,
duration: 2000,
duration: 8000,
});
}
notifyNative({
@ -1007,8 +1002,7 @@ export function useChatModelRuntime() {
toast.error(message, {
id: toastId,
description: undefined,
closeButton: false,
duration: 5000,
duration: 8000,
});
}
notifyNative({

View file

@ -0,0 +1,722 @@
// 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 { strFromU8, unzipSync } from "fflate";
export const OPEN_DOCUMENT_SPREADSHEET_MIME =
"application/vnd.oasis.opendocument.spreadsheet";
export const OPEN_DOCUMENT_TEXT_MIME =
"application/vnd.oasis.opendocument.text";
const OFFICE_NAMESPACE = "urn:oasis:names:tc:opendocument:xmlns:office:1.0";
const STYLE_NAMESPACE = "urn:oasis:names:tc:opendocument:xmlns:style:1.0";
const TABLE_NAMESPACE = "urn:oasis:names:tc:opendocument:xmlns:table:1.0";
const TEXT_NAMESPACE = "urn:oasis:names:tc:opendocument:xmlns:text:1.0";
const OPEN_DOCUMENT_CELL_VALUE_ATTRIBUTES = [
"string-value",
"value",
"boolean-value",
"date-value",
"time-value",
] as const;
const OPEN_DOCUMENT_TEXT_BLOCK_NAMES = ["h", "p"] as const;
const MAX_OPEN_DOCUMENT_ARCHIVE_BYTES = 50 * 1024 * 1024;
const MAX_OPEN_DOCUMENT_XML_BYTES = 10 * 1024 * 1024;
const MAX_REPEATED_OPEN_DOCUMENT_ROWS = 100;
const MAX_REPEATED_OPEN_DOCUMENT_COLUMNS = 100;
const MAX_OPEN_DOCUMENT_COLUMN_INDEX = Number.MAX_SAFE_INTEGER;
export type OpenDocumentAttachmentContent = {
label: "ODS" | "ODT";
text: string;
};
type HiddenOpenDocumentColumnRange = {
start: number;
end: number;
};
type HiddenOpenDocumentColumnRanges = {
ranges: HiddenOpenDocumentColumnRange[];
nextColumn: number;
};
type OpenDocumentXmlFiles = {
contentXml: string;
stylesXml?: string;
};
type OpenDocumentHiddenState = "hidden" | "visible" | "unset";
export async function readOpenDocumentAttachmentContent(
file: File,
filename: string,
contentType: string,
): Promise<OpenDocumentAttachmentContent> {
const { contentXml, stylesXml } = await readOpenDocumentXmlFiles(file);
const doc = parseOpenDocumentXml(contentXml, filename);
const stylesDoc = stylesXml
? parseOpenDocumentXml(stylesXml, `${filename}:styles.xml`)
: undefined;
const isSpreadsheet =
contentType === OPEN_DOCUMENT_SPREADSHEET_MIME ||
filename.toLowerCase().endsWith(".ods");
return {
label: isSpreadsheet ? "ODS" : "ODT",
text: isSpreadsheet
? extractOpenDocumentSpreadsheetText(doc, stylesDoc)
: extractOpenDocumentText(doc),
};
}
export async function readActiveOpenDocumentAttachmentContent(
file: File,
filename: string,
contentType: string,
isActive: () => boolean,
): Promise<OpenDocumentAttachmentContent | null> {
try {
const content = await readOpenDocumentAttachmentContent(
file,
filename,
contentType,
);
return isActive() ? content : null;
} catch (error) {
if (!isActive()) {
return null;
}
throw error;
}
}
async function readOpenDocumentXmlFiles(
file: File,
): Promise<OpenDocumentXmlFiles> {
assertOpenDocumentArchiveSize(file);
let files: Record<string, Uint8Array>;
try {
files = unzipSync(new Uint8Array(await file.arrayBuffer()), {
filter: (entry) => {
const shouldRead =
entry.name === "content.xml" || entry.name === "styles.xml";
if (shouldRead) {
assertOpenDocumentXmlSize(file.name, entry.name, entry.originalSize);
}
return shouldRead;
},
});
} catch (error) {
if (isOpenDocumentSizeError(error)) {
throw error;
}
throw new Error(`Failed to read OpenDocument archive: ${file.name}`, {
cause: error,
});
}
const content = files["content.xml"];
if (!content) {
throw new Error(`OpenDocument file is missing content.xml: ${file.name}`);
}
const styles = files["styles.xml"];
assertOpenDocumentXmlSize(file.name, "content.xml", content.length);
if (styles) {
assertOpenDocumentXmlSize(file.name, "styles.xml", styles.length);
}
return {
contentXml: strFromU8(content),
stylesXml: styles ? strFromU8(styles) : undefined,
};
}
function assertOpenDocumentArchiveSize(file: File): void {
if (file.size > MAX_OPEN_DOCUMENT_ARCHIVE_BYTES) {
throw new Error(`OpenDocument archive is too large: ${file.name}`);
}
}
function assertOpenDocumentXmlSize(
filename: string,
entryName: string,
bytes: number,
): void {
if (bytes > MAX_OPEN_DOCUMENT_XML_BYTES) {
throw new Error(
`OpenDocument XML file is too large: ${filename}:${entryName}`,
);
}
}
function isOpenDocumentSizeError(error: unknown): boolean {
return (
error instanceof Error &&
(error.message.startsWith("OpenDocument archive is too large:") ||
error.message.startsWith("OpenDocument XML file is too large:"))
);
}
function parseOpenDocumentXml(xml: string, filename: string): XMLDocument {
const doc = new DOMParser().parseFromString(xml, "application/xml");
if (doc.getElementsByTagName("parsererror").length > 0) {
throw new Error(`Failed to parse OpenDocument content.xml: ${filename}`);
}
return doc;
}
function extractOpenDocumentText(doc: XMLDocument): string {
const body =
doc.getElementsByTagNameNS(OFFICE_NAMESPACE, "body")[0] ??
doc.documentElement;
const blocks = collectVisibleOpenDocumentTextBlocks(body);
return blocks
.map((block) =>
normalizeOpenDocumentText(extractOpenDocumentInlineText(block)),
)
.filter(Boolean)
.join("\n\n");
}
function extractOpenDocumentSpreadsheetText(
doc: XMLDocument,
stylesDoc?: XMLDocument,
): string {
const hiddenTableStyles = collectHiddenOpenDocumentTableStyles(
doc,
stylesDoc,
);
const body =
doc.getElementsByTagNameNS(OFFICE_NAMESPACE, "body")[0] ??
doc.documentElement;
const tables = getOpenDocumentChildElements(body, OFFICE_NAMESPACE, [
"spreadsheet",
])
.flatMap((spreadsheet) =>
getOpenDocumentChildElements(spreadsheet, TABLE_NAMESPACE, ["table"]),
)
.filter(
(table) =>
!isHiddenOpenDocumentElement(table) &&
!hasHiddenOpenDocumentTableStyle(table, hiddenTableStyles),
);
return tables.map(extractOpenDocumentTableText).filter(Boolean).join("\n\n");
}
function extractOpenDocumentTableText(table: Element): string {
const hiddenColumns = collectHiddenOpenDocumentColumns(table).ranges;
const rows = collectOpenDocumentTableRows(table).flatMap((row) =>
extractOpenDocumentRowText(row, hiddenColumns),
);
if (rows.length === 0) {
return "";
}
const name = getOpenDocumentAttribute(table, TABLE_NAMESPACE, "name");
return name ? `[Sheet: ${name}]\n${rows.join("\n")}` : rows.join("\n");
}
function extractOpenDocumentRowText(
row: Element,
hiddenColumns: HiddenOpenDocumentColumnRange[],
): string[] {
const cells = getOpenDocumentChildElements(row, TABLE_NAMESPACE, [
"table-cell",
"covered-table-cell",
]);
const rowCells: string[] = [];
let columnIndex = 0;
for (const cell of cells) {
const isCoveredCell = cell.localName === "covered-table-cell";
const repeat = getOpenDocumentRepeatCount(
cell,
"number-columns-repeated",
MAX_OPEN_DOCUMENT_COLUMN_INDEX,
);
appendOpenDocumentVisibleCells(
rowCells,
hiddenColumns,
columnIndex,
repeat,
isCoveredCell ? "" : extractOpenDocumentCellText(cell),
);
columnIndex = advanceOpenDocumentColumnIndex(columnIndex, repeat);
}
const line = rowCells.join("\t").replace(/\t+$/g, "");
if (!line.trim()) {
return [];
}
return repeatOpenDocumentValue(
line,
getOpenDocumentRepeatCount(
row,
"number-rows-repeated",
MAX_REPEATED_OPEN_DOCUMENT_ROWS,
),
);
}
function appendOpenDocumentVisibleCells(
rowCells: string[],
hiddenColumns: HiddenOpenDocumentColumnRange[],
columnIndex: number,
repeat: number,
text: string,
): void {
let emitted = 0;
for (
let i = 0;
i < repeat && emitted < MAX_REPEATED_OPEN_DOCUMENT_COLUMNS;
i++
) {
const hiddenEnd = getHiddenOpenDocumentColumnEnd(
hiddenColumns,
columnIndex + i,
);
if (hiddenEnd === null) {
rowCells.push(text);
emitted++;
} else {
i += hiddenEnd - columnIndex - i - 1;
}
}
}
function repeatOpenDocumentValue<T>(value: T, count: number): T[] {
return Array.from({ length: count }, () => value);
}
function extractOpenDocumentCellText(cell: Element): string {
const blocks = collectVisibleOpenDocumentTextBlocks(cell);
const text = blocks
.map((block) =>
normalizeOpenDocumentText(extractOpenDocumentInlineText(block)),
)
.filter(Boolean)
.join("\n");
if (text) {
return text;
}
return getOpenDocumentCellValueText(cell);
}
function getOpenDocumentCellValueText(cell: Element): string {
for (const attributeName of OPEN_DOCUMENT_CELL_VALUE_ATTRIBUTES) {
const value = getOpenDocumentAttribute(
cell,
OFFICE_NAMESPACE,
attributeName,
);
if (value !== null) {
return value;
}
}
return "";
}
function extractOpenDocumentInlineText(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return node.nodeValue ?? "";
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return "";
}
const element = node as Element;
if (isHiddenOpenDocumentElement(element)) {
return "";
}
if (element.namespaceURI === TEXT_NAMESPACE) {
if (element.localName === "hidden-text") {
return (
getOpenDocumentAttribute(element, TEXT_NAMESPACE, "string-value") ??
Array.from(element.childNodes)
.map(extractOpenDocumentInlineText)
.join("")
);
}
if (element.localName === "tab") {
return "\t";
}
if (element.localName === "line-break") {
return "\n";
}
if (element.localName === "s") {
return " ".repeat(
getOpenDocumentRepeatCount(
element,
"c",
MAX_REPEATED_OPEN_DOCUMENT_COLUMNS,
TEXT_NAMESPACE,
),
);
}
}
return Array.from(element.childNodes)
.map(extractOpenDocumentInlineText)
.join("");
}
function normalizeOpenDocumentText(text: string): string {
return text.replace(/[^\S\r\n\t]+/g, " ").trim();
}
function collectVisibleOpenDocumentTextBlocks(root: Element): Element[] {
const matches: Element[] = [];
for (const child of getOpenDocumentChildElementNodes(root)) {
if (isHiddenOpenDocumentElement(child)) {
continue;
}
if (
child.namespaceURI === TEXT_NAMESPACE &&
OPEN_DOCUMENT_TEXT_BLOCK_NAMES.includes(
child.localName as (typeof OPEN_DOCUMENT_TEXT_BLOCK_NAMES)[number],
) &&
!isOpenDocumentParagraphHidden(child)
) {
matches.push(child);
} else {
matches.push(...collectVisibleOpenDocumentTextBlocks(child));
}
}
return matches;
}
function isHiddenOpenDocumentElement(element: Element): boolean {
if (element.namespaceURI === TABLE_NAMESPACE) {
const visibility = getOpenDocumentAttribute(
element,
TABLE_NAMESPACE,
"visibility",
);
return (
visibility === "collapse" ||
visibility === "filter" ||
((element.localName === "table" ||
element.localName === "table-row-group" ||
element.localName === "table-column-group") &&
getOpenDocumentAttribute(element, TABLE_NAMESPACE, "display") ===
"false")
);
}
if (element.namespaceURI === OFFICE_NAMESPACE) {
return (
element.localName === "annotation" || element.localName === "change-info"
);
}
if (element.namespaceURI === TEXT_NAMESPACE) {
return (
(element.localName === "section" &&
isHiddenOpenDocumentSection(element)) ||
(element.localName === "hidden-text" &&
getOpenDocumentHiddenState(element) === "hidden") ||
(element.localName === "hidden-paragraph" &&
getOpenDocumentHiddenState(element) === "hidden") ||
element.localName === "tracked-changes" ||
element.localName === "changed-region" ||
element.localName === "deletion" ||
element.localName === "insertion" ||
element.localName === "format-change"
);
}
return false;
}
function isHiddenOpenDocumentSection(element: Element): boolean {
const display = getOpenDocumentAttribute(element, TEXT_NAMESPACE, "display");
return (
display === "none" ||
(display === "condition" &&
getOpenDocumentAttribute(element, TEXT_NAMESPACE, "condition") !== null)
);
}
function isOpenDocumentParagraphHidden(element: Element): boolean {
const visibility = getOpenDocumentParagraphVisibility(element);
return visibility.hidden && !visibility.visible;
}
function getOpenDocumentParagraphVisibility(element: Element): {
hidden: boolean;
visible: boolean;
} {
let hidden = false;
let visible = false;
for (const child of getOpenDocumentChildElementNodes(element)) {
const isHiddenParagraph =
child.namespaceURI === TEXT_NAMESPACE &&
child.localName === "hidden-paragraph";
if (isHiddenParagraph) {
const hiddenState = getOpenDocumentHiddenState(child);
hidden ||= hiddenState === "hidden";
visible ||= hiddenState === "visible";
}
if (isHiddenParagraph || isHiddenOpenDocumentElement(child)) {
continue;
}
const childVisibility = getOpenDocumentParagraphVisibility(child);
hidden ||= childVisibility.hidden;
visible ||= childVisibility.visible;
}
return { hidden, visible };
}
function getOpenDocumentHiddenState(element: Element): OpenDocumentHiddenState {
const isHidden = getOpenDocumentAttribute(
element,
TEXT_NAMESPACE,
"is-hidden",
);
if (isHidden === "true") {
return "hidden";
}
if (isHidden === "false") {
return "visible";
}
return getOpenDocumentAttribute(element, TEXT_NAMESPACE, "condition") !== null
? "hidden"
: "unset";
}
function collectOpenDocumentTableRows(root: Element): Element[] {
const rows: Element[] = [];
for (const child of getOpenDocumentChildElementNodes(root)) {
if (isHiddenOpenDocumentElement(child)) {
continue;
}
if (
child.namespaceURI === TABLE_NAMESPACE &&
child.localName === "table-row"
) {
rows.push(child);
} else if (
child.namespaceURI !== TABLE_NAMESPACE ||
["table-row-group", "table-rows", "table-header-rows"].includes(
child.localName,
)
) {
rows.push(...collectOpenDocumentTableRows(child));
}
}
return rows;
}
function collectHiddenOpenDocumentColumns(
root: Element,
hidden = false,
startColumn = 0,
): HiddenOpenDocumentColumnRanges {
const ranges: HiddenOpenDocumentColumnRange[] = [];
let column = startColumn;
for (const child of getOpenDocumentChildElementNodes(root)) {
if (child.namespaceURI !== TABLE_NAMESPACE) {
continue;
}
const childHidden = hidden || isHiddenOpenDocumentElement(child);
if (child.localName === "table-column") {
const repeat = getOpenDocumentRepeatCount(
child,
"number-columns-repeated",
MAX_OPEN_DOCUMENT_COLUMN_INDEX,
);
const nextColumn = advanceOpenDocumentColumnIndex(column, repeat);
if (childHidden) {
ranges.push({ start: column, end: nextColumn });
}
column = nextColumn;
} else if (
["table-column-group", "table-columns", "table-header-columns"].includes(
child.localName,
)
) {
const childRanges = collectHiddenOpenDocumentColumns(
child,
childHidden,
column,
);
ranges.push(...childRanges.ranges);
column = childRanges.nextColumn;
}
}
return { ranges, nextColumn: column };
}
function getHiddenOpenDocumentColumnEnd(
ranges: HiddenOpenDocumentColumnRange[],
column: number,
): number | null {
for (const range of ranges) {
if (column < range.start) {
return null;
}
if (column < range.end) {
return range.end;
}
}
return null;
}
function advanceOpenDocumentColumnIndex(
column: number,
repeat: number,
): number {
return Math.min(column + repeat, MAX_OPEN_DOCUMENT_COLUMN_INDEX);
}
function collectOpenDocumentElements(
root: Element,
namespaceUri: string,
localNames: string[],
): Element[] {
const matches: Element[] = [];
for (const child of getOpenDocumentChildElementNodes(root)) {
if (isHiddenOpenDocumentElement(child)) {
continue;
}
if (
child.namespaceURI === namespaceUri &&
localNames.includes(child.localName)
) {
matches.push(child);
} else {
matches.push(
...collectOpenDocumentElements(child, namespaceUri, localNames),
);
}
}
return matches;
}
function collectHiddenOpenDocumentTableStyles(
doc: XMLDocument,
stylesDoc?: XMLDocument,
): Set<string> {
const hidden = new Set<string>();
const styles = [
...collectOpenDocumentElements(doc.documentElement, STYLE_NAMESPACE, [
"style",
]),
...(stylesDoc
? collectOpenDocumentElements(
stylesDoc.documentElement,
STYLE_NAMESPACE,
["style"],
)
: []),
];
for (const style of styles) {
const name = getOpenDocumentAttribute(style, STYLE_NAMESPACE, "name");
if (
!name ||
getOpenDocumentAttribute(style, STYLE_NAMESPACE, "family") !== "table"
) {
continue;
}
const hidesTable =
getOpenDocumentAttribute(style, TABLE_NAMESPACE, "display") === "false" ||
getOpenDocumentChildElements(style, STYLE_NAMESPACE, [
"table-properties",
]).some(
(properties) =>
getOpenDocumentAttribute(properties, TABLE_NAMESPACE, "display") ===
"false",
);
if (hidesTable) {
hidden.add(name);
}
}
return hidden;
}
function hasHiddenOpenDocumentTableStyle(
table: Element,
hiddenTableStyles: Set<string>,
): boolean {
const styleName = getOpenDocumentAttribute(
table,
TABLE_NAMESPACE,
"style-name",
);
return styleName !== null && hiddenTableStyles.has(styleName);
}
function getOpenDocumentChildElements(
root: Element,
namespaceUri: string,
localNames: string[],
): Element[] {
return getOpenDocumentChildElementNodes(root).filter(
(child) =>
child.namespaceURI === namespaceUri &&
localNames.includes(child.localName),
);
}
function getOpenDocumentChildElementNodes(root: Element): Element[] {
return Array.from(root.childNodes).filter(
(child): child is Element => child.nodeType === Node.ELEMENT_NODE,
);
}
function getOpenDocumentRepeatCount(
element: Element,
name: string,
max: number,
namespaceUri = TABLE_NAMESPACE,
): number {
const value = getOpenDocumentAttribute(element, namespaceUri, name);
if (!value) {
return 1;
}
const count = Number.parseInt(value, 10);
if (!Number.isFinite(count) || count < 1) {
return 1;
}
return Math.min(count, max);
}
function getOpenDocumentAttribute(
element: Element,
namespaceUri: string,
name: string,
): string | null {
const value = element.getAttributeNS(namespaceUri, name);
return value === "" && !element.hasAttributeNS(namespaceUri, name)
? null
: value;
}

View file

@ -32,9 +32,22 @@ import {
useRef,
} from "react";
import { extractText, getDocumentProxy } from "unpdf";
import { toast } from "sonner";
import { authFetch } from "@/features/auth";
import { createOpenAIStreamAdapter } from "./api/chat-adapter";
import { db } from "./db";
import {
loadExternalProviders,
parseExternalModelId,
providerTypeSupportsVision,
} from "./external-providers";
import {
OPEN_DOCUMENT_SPREADSHEET_MIME,
OPEN_DOCUMENT_TEXT_MIME,
type OpenDocumentAttachmentContent,
readActiveOpenDocumentAttachmentContent,
readOpenDocumentAttachmentContent,
} from "./open-document";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import type { MessageRecord, ModelType } from "./types";
import {
@ -42,6 +55,7 @@ import {
markChatThreadDeleted,
} from "./utils/chat-thread-tombstones";
import { syncExportedRepositoryToDexie } from "./utils/delete-thread-message";
import { getImageInputUnavailableReason } from "./utils/image-input-support";
const DEFAULT_SUGGESTIONS = [
{
@ -78,6 +92,37 @@ class VisionImageAdapter implements AttachmentAdapter {
accept = "image/jpeg,image/png,image/webp,image/gif";
async add({ file }: { file: File }): Promise<PendingAttachment> {
const state = useChatRuntimeStore.getState();
const checkpoint = state.params.checkpoint;
const activeModel = state.models.find((m) => m.id === checkpoint);
const externalSelection = parseExternalModelId(checkpoint);
const isExternalModel = externalSelection !== null;
const modelLoaded = !!checkpoint && !state.modelLoading;
let externalSupportsVision: boolean | null = null;
let externalModelLabel: string | null = null;
if (externalSelection !== null) {
const providers = loadExternalProviders();
const provider = providers.find(
(p) => p.id === externalSelection.providerId,
);
externalSupportsVision = providerTypeSupportsVision(
provider?.providerType,
);
externalModelLabel = externalSelection.modelId;
}
const unavailableReason = getImageInputUnavailableReason({
activeModel,
isExternalModel,
externalSupportsVision,
externalModelLabel,
loadedIsMultimodal: state.loadedIsMultimodal,
modelLoaded,
});
if (unavailableReason) {
toast.error(unavailableReason);
throw new Error(unavailableReason);
}
const maxSize = 20 * 1024 * 1024;
if (file.size > maxSize) {
throw new Error("Image size exceeds 20MB limit");
@ -260,6 +305,95 @@ class DocxAttachmentAdapter implements AttachmentAdapter {
}
}
class OpenDocumentAttachmentAdapter implements AttachmentAdapter {
private readonly active = new Set<string>();
private readonly sending = new Set<string>();
private readonly content = new Map<
string,
Promise<OpenDocumentAttachmentContent | null>
>();
accept = [
".ods",
".odt",
OPEN_DOCUMENT_SPREADSHEET_MIME,
OPEN_DOCUMENT_TEXT_MIME,
].join(",");
async *add({ file }: { file: File }): AsyncGenerator<PendingAttachment, void> {
const id = crypto.randomUUID();
this.active.add(id);
const attachment = {
id,
type: "document",
name: file.name,
contentType: file.type,
file,
status: { type: "running", reason: "uploading", progress: 0 },
} satisfies PendingAttachment;
yield attachment;
const content = readActiveOpenDocumentAttachmentContent(
file,
file.name,
file.type,
() => this.active.has(id),
);
this.content.set(id, content);
try {
if ((await content) && this.active.has(id) && !this.sending.has(id)) {
yield {
...attachment,
status: { type: "requires-action", reason: "composer-send" },
};
}
} catch {
this.active.delete(id);
this.content.delete(id);
if (!this.sending.has(id)) {
yield { ...attachment, status: { type: "incomplete", reason: "error" } };
}
}
}
async send(attachment: PendingAttachment): Promise<CompleteAttachment> {
this.sending.add(attachment.id);
try {
const content =
(await this.content.get(attachment.id)) ??
(await readOpenDocumentAttachmentContent(
attachment.file,
attachment.name,
attachment.contentType ?? "",
));
const { label, text } = content;
return {
id: attachment.id,
type: "document",
name: attachment.name,
contentType: attachment.contentType,
content: [
{ type: "text", text: `[${label}: ${attachment.name}]\n${text}` },
],
status: { type: "complete" },
};
} finally {
this.active.delete(attachment.id);
this.content.delete(attachment.id);
this.sending.delete(attachment.id);
}
}
remove(attachment: { id: string }): Promise<void> {
this.active.delete(attachment.id);
this.sending.delete(attachment.id);
this.content.delete(attachment.id);
return Promise.resolve();
}
}
function clip(input: string, maxLen: number): string {
const text = input.replace(/\s+/g, " ").trim();
if (text.length <= maxLen) return text;
@ -703,6 +837,7 @@ function useStudioRuntimeAdapters(): StudioRuntimeAdapters {
new HtmlAttachmentAdapter(),
new PDFAttachmentAdapter(),
new DocxAttachmentAdapter(),
new OpenDocumentAttachmentAdapter(),
]),
[],
);

View file

@ -14,11 +14,13 @@ import {
import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params";
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
import { isTauri } from "@/lib/api-base";
import { isMultimodalResponse } from "./types/api";
import { getImageInputUnavailableReason } from "./utils/image-input-support";
import { useAui } from "@assistant-ui/react";
import { ArrowUpIcon, GlobeIcon, HeadphonesIcon, LightbulbIcon, LightbulbOffIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
import { loadModel, validateModel } from "./api/chat-api";
import { parseExternalModelId } from "./external-providers";
import { parseExternalModelId, providerTypeSupportsVision } from "./external-providers";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import {
type ReasoningEffort,
@ -66,6 +68,11 @@ function isNativeComposing(event: Event) {
return "isComposing" in event && (event as InputEvent).isComposing === true;
}
// Mirrors the threshold in thread.tsx — see the comment there. Chrome on
// Windows-over-WSL (issue #5546) never fires `compositionend` after the
// IME commit, so the compose flag would otherwise stay true forever.
const IME_STUCK_TIMEOUT_MS = 2500;
function fileToBase64DataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@ -282,6 +289,7 @@ export function SharedComposer({
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const composingRef = useRef(false);
const stuckImeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const audioInputRef = useRef<HTMLInputElement>(null);
@ -294,6 +302,7 @@ export function SharedComposer({
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
);
const loadedIsMultimodal = useChatRuntimeStore((s) => s.loadedIsMultimodal);
const supportsReasoning = useChatRuntimeStore((s) => s.supportsReasoning);
const reasoningAlwaysOn = useChatRuntimeStore((s) => s.reasoningAlwaysOn);
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
@ -318,10 +327,28 @@ export function SharedComposer({
(s) => s.lastOpenRouterChosenModel,
);
const externalSelection = parseExternalModelId(checkpoint);
const isExternalModel = externalSelection !== null;
const selectedExternalProvider =
externalSelection != null
? externalProviders.find((p) => p.id === externalSelection.providerId)
: undefined;
const imageUnavailableReason = getImageInputUnavailableReason({
activeModel,
isExternalModel,
externalSupportsVision: providerTypeSupportsVision(
selectedExternalProvider?.providerType,
),
externalModelLabel: externalSelection?.modelId ?? null,
loadedIsMultimodal,
modelLoaded,
});
const isCompareMode = Boolean(model1?.id || model2?.id);
// Attach-time gate. Compare mode defers to send: the catalog can lag
// behind a model's real capabilities (e.g., a GGUF whose mmproj
// arrives after the catalog snapshot), and we only sync the models[]
// entry after ensureModelLoaded runs at send time. Single mode uses
// the loaded model's runtime capability.
const attachUnavailableReason = isCompareMode ? null : imageUnavailableReason;
const effectiveExternalModelId =
selectedExternalProvider?.providerType === "openrouter" &&
externalSelection?.modelId === "openrouter/free" &&
@ -422,6 +449,7 @@ export function SharedComposer({
const addFiles = useCallback((files: FileList | null) => {
if (!files?.length) return;
const next: PendingImage[] = [];
let droppedImageForUnavailable = false;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file) continue;
@ -436,25 +464,76 @@ export function SharedComposer({
// Handle image files
if (!file.type.match(/^image\/(jpeg|png|webp|gif)$/i)) continue;
if (file.size > MAX_IMAGE_SIZE) continue;
if (attachUnavailableReason) {
droppedImageForUnavailable = true;
continue;
}
next.push({ id: crypto.randomUUID(), file });
}
if (droppedImageForUnavailable && attachUnavailableReason) {
toast.error(attachUnavailableReason);
}
setPendingImages((prev) => [...prev, ...next]);
}, [setPendingAudioStore]);
}, [setPendingAudioStore, attachUnavailableReason]);
const removePendingImage = useCallback((id: string) => {
setPendingImages((prev) => prev.filter((p) => p.id !== id));
}, []);
function clearStuckImeTimer() {
if (stuckImeTimerRef.current) {
clearTimeout(stuckImeTimerRef.current);
stuckImeTimerRef.current = null;
}
}
function setCompositionState(next: boolean) {
composingRef.current = next;
setIsComposing(next);
clearStuckImeTimer();
if (next) {
stuckImeTimerRef.current = setTimeout(() => {
stuckImeTimerRef.current = null;
composingRef.current = false;
setIsComposing(false);
}, IME_STUCK_TIMEOUT_MS);
}
}
function refreshStuckImeTimer() {
if (!composingRef.current) {
return;
}
clearStuckImeTimer();
stuckImeTimerRef.current = setTimeout(() => {
stuckImeTimerRef.current = null;
composingRef.current = false;
setIsComposing(false);
}, IME_STUCK_TIMEOUT_MS);
}
useEffect(() => () => clearStuckImeTimer(), []);
async function send() {
if (composingRef.current) return;
const msg = text.trim();
if (!msg && pendingImages.length === 0 && !pendingAudio) return;
const hasCompareHandles = Boolean(
handlesRef.current["model1"] || handlesRef.current["model2"],
);
const isGeneralizedCompare =
hasCompareHandles && Boolean(model1?.id || model2?.id);
if (pendingImages.length > 0 && !isGeneralizedCompare && imageUnavailableReason) {
// Single mode: the loaded model's runtime capability is known
// here. Compare mode defers — each ensureModelLoaded below sets
// loadedIsMultimodal for its side, and the chat-adapter's
// pre-stream gate runs per-side against that fresh state.
toast.error(imageUnavailableReason);
return;
}
const content: CompareMessagePart[] = [];
for (const { file } of pendingImages) {
try {
@ -479,8 +558,6 @@ export function SharedComposer({
textareaRef.current?.focus();
// Generalized compare: load each model before dispatching to its side
const hasCompareHandles = Boolean(handlesRef.current["model1"] || handlesRef.current["model2"]);
const isGeneralizedCompare = hasCompareHandles && Boolean(model1?.id || model2?.id);
if (isGeneralizedCompare) {
const store = useChatRuntimeStore.getState();
const maxSeqLength = store.params.maxSeqLength;
@ -541,7 +618,36 @@ export function SharedComposer({
reasoningStyle: resp.reasoning_style ?? "enable_thinking",
supportsPreserveThinking: resp.supports_preserve_thinking ?? false,
supportsTools: resp.supports_tools ?? false,
loadedIsMultimodal: isMultimodalResponse(resp),
});
// Sync the models[] entry with the load response so the
// attach/send gates read fresh capabilities. /api/models/list
// can lag behind a model's actual state (e.g., a GGUF whose
// mmproj was downloaded after the catalog snapshot).
const currentModels = useChatRuntimeStore.getState().models;
const idx = currentModels.findIndex((m) => m.id === sel.id);
const synced = {
isVision: Boolean(resp.is_vision),
isGguf: Boolean(resp.is_gguf),
isAudio: Boolean(resp.is_audio),
audioType: resp.audio_type ?? null,
hasAudioInput: Boolean(resp.has_audio_input),
};
if (idx === -1) {
store.setModels([
...currentModels,
{
id: sel.id,
name: resp.display_name ?? sel.id,
isLora: sel.isLora,
...synced,
},
]);
} else {
const next = [...currentModels];
next[idx] = { ...next[idx], ...synced };
store.setModels(next);
}
return resp.status;
}
@ -611,8 +717,17 @@ export function SharedComposer({
function onKeyDown(e: KeyboardEvent) {
// IME composition (Japanese/Chinese/Korean): Enter commits the candidate.
// Don't hijack it. See issue #5318.
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
// Don't hijack it. See issue #5318. Re-pin composingRef in case the stuck
// watchdog (#5546) cleared it during a long candidate-window pause; this
// keeps a follow-up click-Send from submitting preedit text. Re-arm the
// watchdog on the same path — without it the WSL+Chrome no-compositionend
// case would leave composingRef pinned forever after an IME keypress and
// re-lock Send.
if (e.nativeEvent.isComposing || e.keyCode === 229) {
composingRef.current = true;
refreshStuckImeTimer();
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!busy) {
@ -682,6 +797,9 @@ export function SharedComposer({
onCompositionStart={() => {
setCompositionState(true);
}}
onCompositionUpdate={() => {
refreshStuckImeTimer();
}}
onCompositionEnd={(e: CompositionEvent<HTMLTextAreaElement>) => {
setCompositionState(false);
setText(e.currentTarget.value);
@ -690,9 +808,12 @@ export function SharedComposer({
placeholder="Send to both models..."
className="composer-input"
rows={1}
// dir="auto" auto-detects RTL (Arabic / Hebrew / Persian / Urdu)
// from the first strong character; no effect on LTR scripts.
dir="auto"
/>
<div className="composer-action-wrapper">
<div className="flex items-center gap-1">
<div className="flex items-center gap-0.5">
<input
ref={fileInputRef}
type="file"
@ -710,7 +831,13 @@ export function SharedComposer({
variant="ghost"
size="icon"
className="size-8.5 rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
onClick={() => fileInputRef.current?.click()}
onClick={() => {
// The picker accepts both image and audio. Don't gate the
// button on image-availability — addFiles still filters
// image files per-file when the loaded model can't take
// them, while audio attach always works.
fileInputRef.current?.click();
}}
aria-label="Add Attachment"
>
<PlusIcon className="size-5 stroke-[1.5px]" />
@ -748,12 +875,12 @@ export function SharedComposer({
type="button"
disabled={reasoningDisabled}
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningVisualEnabled
? "bg-primary/10 text-primary hover:bg-primary/20"
: "text-muted-foreground hover:bg-muted-foreground/15",
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={`Reasoning effort: ${reasoningEffort}`}
>
@ -838,14 +965,14 @@ export function SharedComposer({
}
}}
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningLockedOn
? "cursor-not-allowed bg-primary/10 text-primary"
? "cursor-not-allowed text-primary"
: reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningEnabled
? "bg-primary/10 text-primary hover:bg-primary/20"
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={
reasoningLockedOn
@ -871,12 +998,12 @@ export function SharedComposer({
disabled={!modelLoaded}
onClick={() => setPreserveThinking(!preserveThinking)}
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
!modelLoaded
? "cursor-not-allowed opacity-40"
: preserveThinking
? "bg-primary/10 text-primary hover:bg-primary/20"
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={
preserveThinking ? "Disable preserve think" : "Enable preserve think"

View file

@ -2,7 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
import {
DEFAULT_INFERENCE_PARAMS,
type ChatLoraSummary,

View file

@ -26,11 +26,21 @@ export interface ThreadRecord {
* if a stale id is sent, the backend surfaces an
* `_toolEvent.type="container_invalidated"` and the chat-adapter
* clears this field so the following turn falls back to auto-create.
*
* Anthropic's code-execution path doesn't need this each turn
* gets a fresh container server-side.
*/
openaiCodeExecContainerId?: string | null;
/**
* Anthropic code_execution container id captured from a prior
* response on this thread. When set, the next turn sends a
* top-level `container` field on /v1/messages so filesystem state
* (files, packages, variables) persists across turns. When
* null/undefined, Anthropic auto-creates a fresh container.
*
* Anthropic containers expire after ~1 hour by default; on a stale
* id the backend surfaces `_toolEvent.type="container_invalidated"`
* and the chat-adapter clears this field so the following turn
* falls back to auto-create.
*/
anthropicCodeExecContainerId?: string | null;
}
export interface MessageRecord {

View file

@ -235,6 +235,15 @@ export interface OpenAIChatCompletionsRequest {
* container. Only meaningful for OpenAI cloud + gpt-5.5 family.
*/
openai_code_exec_container_id?: string | null;
/**
* Anthropic code_execution container id captured from the prior
* response in this chat thread. When set and the Code pill is on,
* the backend forwards a top-level `container` field on
* /v1/messages so filesystem state persists across turns. Unset
* Anthropic auto-creates a fresh container. Only meaningful for
* the Anthropic provider with `code_execution` in `enabled_tools`.
*/
anthropic_code_exec_container_id?: string | null;
}
export interface OpenAIChatDelta {

View file

@ -0,0 +1,60 @@
// 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 type { ChatModelSummary } from "../types/runtime";
export function getImageInputUnavailableReason({
activeModel,
isExternalModel,
externalSupportsVision,
externalModelLabel,
loadedIsMultimodal,
modelLoaded,
}: {
activeModel?: ChatModelSummary;
isExternalModel: boolean;
// true/false = caller knows; null/undefined = unknown (default-allow).
// External selections aren't in runtime.models[], so callers should
// resolve provider-type capability and pass it here.
externalSupportsVision?: boolean | null;
// Fallback toast label when activeModel is missing.
externalModelLabel?: string | null;
loadedIsMultimodal: boolean;
modelLoaded: boolean;
}): string | null {
if (isExternalModel) {
const explicitlyNonVision =
externalSupportsVision === false ||
(activeModel &&
activeModel.isVision === false &&
!activeModel.isAudio &&
!activeModel.hasAudioInput);
if (explicitlyNonVision) {
const label =
activeModel?.name ||
externalModelLabel ||
activeModel?.id ||
"Current model";
return `${label} cannot accept images.`;
}
return null;
}
if (!modelLoaded) return "Load a model before adding images.";
// loadedIsMultimodal is true for vision OR audio. Can't tell them apart
// from that one flag, so only block when activeModel confirms
// audio-only: audio capability set AND isVision === false. Otherwise
// trust the load response. The models-list entry might be stale, or
// not even there yet (gets auto-injected after load).
if (loadedIsMultimodal) {
const isAudioOnly =
Boolean(activeModel?.isAudio || activeModel?.hasAudioInput) &&
activeModel?.isVision === false;
if (!isAudioOnly) return null;
}
const label = activeModel?.name || activeModel?.id || "Current model";
const suffix = activeModel?.isGguf
? " with a valid mmproj before attaching images."
: " before attaching images.";
return `${label} cannot accept images. Load a vision-capable model${suffix}`;
}

View file

@ -2,15 +2,9 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch } from "@/features/auth";
import { readFastApiError } from "@/lib/format-fastapi-error";
async function readError(response: Response): Promise<string> {
try {
const payload = (await response.json()) as { detail?: string; message?: string };
return payload.detail || payload.message || `Request failed (${response.status})`;
} catch {
return `Request failed (${response.status})`;
}
}
const readError = (r: Response): Promise<string> => readFastApiError(r);
async function parseJson<T>(response: Response): Promise<T> {
if (!response.ok) {

View file

@ -3,7 +3,7 @@ import { useNativeIntentStore } from "../store";
import type { NativeIntent } from "../types";
import { XIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
interface NativeModelChipProps {
intent: NativeIntent;

View file

@ -1,5 +1,5 @@
import { useCallback, useRef } from "react";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
import { pickNativeModel } from "./api";
import { useNativeIntentStore } from "./store";
import type { NativeIntent } from "./types";

View file

@ -1,6 +1,6 @@
import { isTauri } from "@/lib/api-base";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
import { registerNativeModelPath } from "./api";
import { useNativeIntentStore } from "./store";
import type { NativeIntent } from "./types";

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch } from "@/features/auth";
import { formatFastApiDetail, readFastApiError } from "@/lib/format-fastapi-error";
const DEFAULT_BASE = "/api/data-recipe";
@ -202,12 +203,18 @@ async function parseErrorResponse(response: Response): Promise<string> {
}
try {
const parsed = JSON.parse(text) as {
detail?: string;
detail?: unknown;
message?: string;
// biome-ignore lint/style/useNamingConvention: api schema
raw_detail?: string;
};
return parsed.detail ?? parsed.message ?? parsed.raw_detail ?? text;
// Use ||, not ??: an array detail is truthy but not nullish, and
// formatFastApiDetail returns null when it cannot flatten the value.
const formatted = formatFastApiDetail(parsed.detail);
if (formatted) return formatted;
if (typeof parsed.message === "string" && parsed.message) return parsed.message;
if (typeof parsed.raw_detail === "string" && parsed.raw_detail) return parsed.raw_detail;
return text;
} catch {
return text;
}
@ -449,22 +456,17 @@ export async function uploadUnstructuredFile(
);
if (res.status === 413) {
const detail = await res.json().catch(() => ({ detail: "File too large" }));
return {
file_id: "",
filename: file.name,
size_bytes: file.size,
status: "error",
error:
typeof detail.detail === "string" ? detail.detail : "File too large",
error: await readFastApiError(res, "File too large"),
};
}
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: "Upload failed" }));
throw new Error(
typeof detail.detail === "string" ? detail.detail : "Upload failed",
);
throw new Error(await readFastApiError(res, "Upload failed"));
}
return res.json();

View file

@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
import { toastError } from "@/shared/toast";
import {
getInferenceStatus,

View file

@ -48,7 +48,10 @@ async function fetchStudioVersions(): Promise<{
studioVersion: string | null;
}> {
try {
const res = await fetch(apiUrl("/api/health"));
const token = getAuthToken();
const headers = new Headers();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(apiUrl("/api/health"), { headers });
if (!res.ok) {
return { packageVersion: null, studioVersion: null };
}

View file

@ -67,7 +67,7 @@ import {
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
import { useShallow } from "zustand/react/shallow";
import { DocumentUploadRedirectDialog } from "./document-upload-redirect-dialog";

View file

@ -26,7 +26,7 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useRef } from "react";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
const chartConfig = {

View file

@ -7,6 +7,7 @@ import type {
UploadDatasetResponse,
} from "../types/datasets";
import { authFetch } from "@/features/auth";
import { readFastApiError } from "@/lib/format-fastapi-error";
type CheckDatasetFormatArgs = {
datasetName: string;
@ -36,8 +37,7 @@ export async function checkDatasetFormat({
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.detail || `Request failed (${res.status})`);
throw new Error(await readFastApiError(res));
}
return res.json();
@ -55,8 +55,7 @@ export async function uploadTrainingDataset(
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.detail || `Upload failed (${res.status})`);
throw new Error(await readFastApiError(res, "Upload failed"));
}
return res.json();
@ -107,8 +106,7 @@ export async function aiAssistMapping({
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.detail || `AI assist failed (${res.status})`);
throw new Error(await readFastApiError(res, "AI assist failed"));
}
return res.json();
@ -117,8 +115,7 @@ export async function aiAssistMapping({
export async function listLocalDatasets(): Promise<LocalDatasetsResponse> {
const res = await authFetch("/api/datasets/local");
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.detail || `Request failed (${res.status})`);
throw new Error(await readFastApiError(res));
}
return res.json();
}

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch } from "@/features/auth";
import { readFastApiError } from "@/lib/format-fastapi-error";
import type {
TrainingRunDeleteResponse,
TrainingRunDetailResponse,
@ -9,14 +10,7 @@ import type {
TrainingRunSummary,
} from "../types/history";
async function readError(response: Response): Promise<string> {
try {
const payload = (await response.json()) as { detail?: string; message?: string };
return payload.detail || payload.message || `Request failed (${response.status})`;
} catch {
return `Request failed (${response.status})`;
}
}
const readError = (r: Response): Promise<string> => readFastApiError(r);
async function parseJson<T>(response: Response): Promise<T> {
if (!response.ok) {

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch } from "@/features/auth";
import { readFastApiError } from "@/lib/format-fastapi-error";
import type {
TrainingStartRequest,
TrainingStartResponse,
@ -17,45 +18,7 @@ function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
type FastApiValidationError = {
loc?: unknown[];
msg?: string;
};
function formatDetail(detail: unknown): string | null {
if (typeof detail === "string" && detail) return detail;
if (!Array.isArray(detail)) return null;
const parts = detail
.map((entry) => {
if (!entry || typeof entry !== "object") return "";
const { loc, msg } = entry as FastApiValidationError;
const path = Array.isArray(loc)
? loc.filter((segment) => segment !== "body").join(".")
: "";
const message = typeof msg === "string" ? msg : "";
if (path && message) return `${path}: ${message}`;
return path || message;
})
.filter(Boolean);
return parts.length > 0 ? parts.join("; ") : null;
}
async function readError(response: Response): Promise<string> {
try {
const payload = (await response.json()) as {
detail?: unknown;
message?: string;
};
const formattedDetail = formatDetail(payload.detail);
if (formattedDetail) return formattedDetail;
if (typeof payload.message === "string" && payload.message) {
return payload.message;
}
return `Request failed (${response.status})`;
} catch {
return `Request failed (${response.status})`;
}
}
const readError = (r: Response): Promise<string> => readFastApiError(r);
async function parseJson<T>(response: Response): Promise<T> {
if (!response.ok) {

View file

@ -3,7 +3,7 @@
import { primeNativeNotificationPermission } from "@/lib/native-notifications";
import { useCallback } from "react";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
import { checkDatasetFormat } from "../api/datasets-api";
import { emitTrainingRunsChanged } from "../events";
import { getTrainingRun } from "../api/history-api";

View file

@ -2,7 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
import { listTrainingRuns } from "../api/history-api";
import {
onTrainingRunDeleted,

View file

@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { apiUrl } from "@/lib/api-base";
import { authFetch } from "@/features/auth";
import { useEffect, useState } from "react";
export interface GpuInfo {
@ -28,7 +28,7 @@ async function fetchGpuOnce(): Promise<GpuInfo> {
fetchPromise = (async () => {
try {
const res = await fetch(apiUrl("/api/system"));
const res = await authFetch("/api/system");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const gpuData = data?.gpu;

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