* Windows installer: repair a stale CPU PyTorch instead of looping forever
A Windows machine with an NVIDIA CUDA 13 driver (e.g. RTX 6000 Pro on enterprise
drivers) could get permanently stuck at:
Stale venv detected (torch cpu != required cu130).
[ERROR] The existing Studio environment needs repair.
Re-run install.ps1 so it can replace the environment safely with rollback.
Re-running install.ps1 did not help. install.ps1 installs torch with
"torch>=2.4,<2.11.0" --index-url .../cu130 but no --force-reinstall, so when a
torch==X+cpu is already present uv treats it as satisfying the range (PEP 440
ignores the +cpu/+cuXXX local label) and makes no change -- the CPU wheel is
never replaced. setup.ps1 then rejects the venv as cpu != cu130 and exits, but it
cannot create a venv or install torch, so the loop never resolves. The migrated-
venv branch also preserves existing torch and never reinstalls it.
After the install step, detect the installed torch flavor (cuXXX/cpu/rocm) and,
when it does not match the tag implied by the selected index, force-reinstall the
torch/torchvision/torchaudio triplet from the correct index via three
--reinstall-package flags. No-op on a healthy matching venv; skipped for
--no-torch, ROCm (already --force-reinstalls), and CPU-only machines.
Adds two pure helpers (ConvertTo-TorchFlavorTag, Get-ExpectedTorchFlavorTag), a
PowerShell unit test (tests/studio/test_torch_flavor.ps1), and a CI parse gate for
install.ps1 (previously unparsed).
* install.sh: repair a stale CPU PyTorch on Linux too (parity with install.ps1)
install.sh has the same latent bug as the Windows installer: the CUDA torch
install uses "torch>=2.4,<2.11.0" --index-url .../cuXXX with no
--force-reinstall, so an already-present torch==X+cpu satisfies the version
range (PEP 440 ignores the +cpu/+cuXXX local label) and uv leaves it in place.
The migrated-venv branch also preserves existing torch. Unlike Windows there is
no stale-venv check in setup.sh, so on Linux the symptom is silent CPU training
rather than a hard loop -- same root cause.
Mirror the install.ps1 fix: after the install block, detect the installed torch
flavor (_torch_flavor_tag) and, when it does not match the index tag
(_expected_torch_flavor_tag), force-reinstall the torch/torchvision/torchaudio
triplet from the selected index via --reinstall-package. No-op on a healthy
matching venv; skipped for --no-torch, ROCm (its own repair force-reinstalls),
and CPU-only / macOS hosts. Adds tests/sh/test_torch_flavor.sh (run in
studio-backend-ci and run_all.sh).
* Installer: catch CPU-fallback on AMD/WSL too (repair ROCm, warn when unfixable)
Extend the torch-flavor safety net beyond NVIDIA:
- install.sh now auto-repairs a stale CPU torch on standard pytorch.org ROCm
indexes too (the rocm-index install path lacked --force-reinstall, unlike the
Windows ROCm install). Reuses the rocm-adjusted $TORCH_CONSTRAINT + rocm index,
so it pulls the correct ROCm wheels.
- Both installers gain a universal post-install warning: when a GPU build was
expected (cuXXX / rocm, including the repo.amd.com gfx* arch indexes) but torch
is still CPU-only, warn loudly instead of silently training on CPU. This catches
the cases auto-repair cannot safely fix (AMD gfx arch indexes that need
--find-links, a migrated AMD venv on Windows where the ROCm install was skipped).
- Mac / Intel / CPU-only hosts resolve to the cpu index -> expected == installed
-> no-op, no false warning. WSL uses install.sh, so the NVIDIA repair + warning
apply there.
Adds Get-InstalledTorchTag (ps1) and _torch_index_repairable (sh) helpers and
extends both unit tests. gfx*/AMD indexes now map to the 'rocm' expected flavor.
* Installer: tighten torch-flavor comments (no logic change)
Condense the rationale comments added for the stale/CPU PyTorch repair in
install.ps1, install.sh and the two helper unit tests; same intent, fewer
lines. Comment-only: AST parse of install.ps1/setup.ps1 clean, helper unit
tests (15 ps1, 24 sh under bash and dash) and the integration sims
(24 ps1, 28 sh) still pass, banner markers the sims slice on are unchanged.
* Installer: bound torch probe, auto-repair gfx, fix ROCm gate parity
install.ps1: in Get-InstalledTorchTag, call WaitForExit(30000) and drain stdout
and stderr asynchronously instead of reading stdout synchronously first, so a
hung or noisy "import torch" (a wedged CUDA/driver, the exact failure this PR
targets) can no longer block the probe past the timeout.
install.sh and install.ps1: treat the repo.amd.com gfx* indexes as plain
--index-url reinstallable. They are PEP 503 simple indexes uv resolves in full
(torch plus every transitive dep) via --index-url, the same URLs the fresh
ROCm install paths already use, so a stale CPU torch on AMD Strix now auto-repairs
to the correct ROCm build instead of only warning.
install.sh: include */gfx* alongside */rocm* in the bitsandbytes install and
ROCm torch repair gates, so a custom UNSLOTH_AMD_ROCM_MIRROR whose path lacks
/rocm/ still installs the AMD bitsandbytes build and repairs ROCm torch.
tests/sh/test_torch_flavor.sh: gfx indexes now assert repairable, plus a
gfx1151 case and an unknown-mirror not-repairable case.
* install.ps1: guard Get-InstalledTorchTag against an empty python path
Make the early return explicit for an empty $PythonExe instead of relying on
Test-Path -LiteralPath '' returning false, so the probe stays safe under
Set-StrictMode or a future refactor that drops the [string] annotation.
1244 lines
56 KiB
YAML
1244 lines
56 KiB
YAML
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
|
|
|
# Three end-to-end smoke jobs that boot a freshly-installed Studio and
|
|
# exercise the surfaces real users hit through the OpenAI / Anthropic
|
|
# SDKs and curl, on the FREE windows-latest runner. Each job picks the
|
|
# smallest model that exercises the behaviour under test, primes
|
|
# HF_HOME via actions/cache, and shares the install.ps1 --local
|
|
# --no-torch bootstrap.
|
|
#
|
|
# 1. OpenAI, Anthropic API tests
|
|
# gemma-3-270m-it UD-Q4_K_XL (~254 MiB).
|
|
# 2. Tool calling Tests
|
|
# Qwen3.5-2B UD-Q4_K_XL (~890 MiB).
|
|
# 3. JSON, images
|
|
# Qwen3-VL-2B-Instruct UD-IQ2_XXS + mmproj-F16 (~1.4 GiB total).
|
|
# Within the 14 GB windows-latest SSD budget.
|
|
|
|
name: Windows Studio GGUF CI
|
|
|
|
on:
|
|
pull_request:
|
|
paths:
|
|
- 'studio/**'
|
|
- 'unsloth/**'
|
|
- 'unsloth_cli/**'
|
|
- 'install.ps1'
|
|
- 'pyproject.toml'
|
|
- '.github/workflows/studio-windows-inference-smoke.yml'
|
|
push:
|
|
branches: [main, pip]
|
|
workflow_dispatch:
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Job 1: OpenAI, Anthropic API tests
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
openai-anthropic:
|
|
name: OpenAI, Anthropic API tests
|
|
runs-on: windows-latest
|
|
timeout-minutes: 30
|
|
defaults:
|
|
run:
|
|
shell: bash
|
|
env:
|
|
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
|
GGUF_VARIANT: UD-Q4_K_XL
|
|
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
|
STUDIO_PORT: '18888'
|
|
HF_HOME: ${{ github.workspace }}/hf-cache
|
|
# Force UTF-8 for stdio (Windows defaults to cp1252; hf
|
|
# download / Studio CLI print "✓" checkmarks and crash
|
|
# otherwise).
|
|
PYTHONIOENCODING: utf-8
|
|
PYTHONUTF8: '1'
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
with:
|
|
persist-credentials: false
|
|
|
|
# Fast GPU-free gate: parse install.ps1 + setup.ps1 and run the PowerShell
|
|
# unit tests (CUDA-toolkit + torch-flavor helpers) before the heavy GGUF smoke.
|
|
- name: PowerShell installer unit tests
|
|
shell: pwsh
|
|
run: |
|
|
foreach ($f in @('install.ps1', 'studio/setup.ps1')) {
|
|
$errs = $null
|
|
[void][System.Management.Automation.Language.Parser]::ParseFile(
|
|
(Resolve-Path $f).Path, [ref]$null, [ref]$errs)
|
|
if ($errs) { $errs | ForEach-Object { $_.ToString() }; exit 1 }
|
|
Write-Host "$f parsed with no errors"
|
|
}
|
|
pwsh -NoProfile -File tests/studio/test_resolve_cuda_toolkit.ps1
|
|
pwsh -NoProfile -File tests/studio/test_torch_flavor.ps1
|
|
|
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: '22'
|
|
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
|
with:
|
|
python-version: '3.12'
|
|
|
|
# Split restore + save (rather than the one-step actions/cache) so a
|
|
# transient restore-side failure does not kill the whole job. v5 has a
|
|
# known flake where it logs "Cache hit for: <key>" and then exits
|
|
# non-zero without actually extracting the archive (see
|
|
# actions/cache#1621 and github community discussion #163260).
|
|
# continue-on-error on restore masks that failure so the Prime step
|
|
# below can re-download from HF and the job keeps running. Save then
|
|
# populates the cache key on a real miss only; cache keys are
|
|
# immutable, so a corrupted cached entry persists until the -v1
|
|
# suffix below is bumped.
|
|
- name: Restore HF_HOME cache for ${{ env.GGUF_REPO }}
|
|
id: cache-hf
|
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
continue-on-error: true
|
|
with:
|
|
path: hf-cache
|
|
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
|
|
|
- name: Prime HF_HOME with the GGUF
|
|
id: prime-hf
|
|
# Run on a real cache miss AND on the silent-restore-failure mode
|
|
# described above (outcome != success).
|
|
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
|
env:
|
|
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
|
run: |
|
|
python -m pip install --upgrade huggingface_hub
|
|
mkdir -p hf-cache
|
|
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
|
|
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
|
|
|
|
- name: Save HF_HOME cache for ${{ env.GGUF_REPO }}
|
|
# Only write a fresh cache entry when we actually rebuilt the
|
|
# directory (Prime ran and succeeded). Skipping when Prime is
|
|
# skipped avoids "already exists" save warnings on the happy path.
|
|
if: always() && steps.prime-hf.outcome == 'success'
|
|
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
with:
|
|
path: hf-cache
|
|
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
|
|
|
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
|
|
shell: pwsh
|
|
# See studio-windows-update-smoke.yml for the full rationale.
|
|
# tl;dr: setup.ps1 needs npm >=11 to skip a 35 s winget Node
|
|
# reinstall, and Defender's real-time scan dominates the
|
|
# frontend / uv-pip-extract steps.
|
|
run: |
|
|
$ProgressPreference = 'SilentlyContinue'
|
|
Write-Host "npm version before upgrade: $(npm -v)"
|
|
npm install -g 'npm@^11' 2>&1 | Out-Host
|
|
Write-Host "npm version after upgrade: $(npm -v)"
|
|
# NOTE: do NOT pre-create these directories. See
|
|
# studio-windows-update-smoke.yml for the full rationale --
|
|
# creating an empty studio/frontend/dist trips setup.ps1's
|
|
# mtime-based staleness check into "frontend up to date, skip
|
|
# rebuild" and Studio boots with an empty dist directory.
|
|
# Add-MpPreference accepts paths that do not yet exist.
|
|
foreach ($p in @(
|
|
"$env:USERPROFILE\.unsloth",
|
|
"$env:USERPROFILE\AppData\Local\uv",
|
|
"$env:GITHUB_WORKSPACE\studio\frontend\node_modules",
|
|
"$env:GITHUB_WORKSPACE\studio\frontend\dist"
|
|
)) {
|
|
try {
|
|
Add-MpPreference -ExclusionPath $p -ErrorAction Stop
|
|
Write-Host "Defender exclusion added: $p"
|
|
} catch {
|
|
Write-Host "Defender exclusion skipped ($($_.Exception.Message)): $p"
|
|
}
|
|
}
|
|
|
|
- name: Install Studio (--local, --no-torch)
|
|
shell: pwsh
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
|
run: |
|
|
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
|
# *>&1 captures Write-Host (Information stream) output;
|
|
# plain 2>&1 does not. setup.ps1 emits "prebuilt installed
|
|
# and validated" via Write-Host, and we grep for that.
|
|
$ProgressPreference = 'SilentlyContinue'
|
|
& ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log
|
|
|
|
- name: Assert install.ps1 used the Windows llama.cpp prebuilt
|
|
run: |
|
|
# Filesystem check; setup.ps1's stream output isn't captured.
|
|
LLAMA_DIR=~/.unsloth/llama.cpp
|
|
INFO="$LLAMA_DIR/UNSLOTH_PREBUILT_INFO.json"
|
|
BIN="$LLAMA_DIR/build/bin/Release/llama-server.exe"
|
|
if grep -q "falling back to source build" logs/install.log; then
|
|
echo "::error::install.ps1 fell back to source-build llama.cpp on Windows."
|
|
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
|
exit 1
|
|
fi
|
|
if [ ! -f "$INFO" ]; then
|
|
echo "::error::no UNSLOTH_PREBUILT_INFO.json at $INFO."
|
|
ls -la "$LLAMA_DIR" || true
|
|
exit 1
|
|
fi
|
|
if [ ! -f "$BIN" ]; then
|
|
echo "::error::no llama-server.exe at $BIN."
|
|
ls -la "$LLAMA_DIR/build/bin" || true
|
|
exit 1
|
|
fi
|
|
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
|
|
cat "$INFO"
|
|
|
|
- name: Add Studio shim to GITHUB_PATH
|
|
run: |
|
|
SHIM_DIR=~/.unsloth/studio/bin
|
|
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
|
|
echo "::error::unsloth.exe shim not found at $SHIM_DIR"
|
|
ls -la ~/.unsloth/studio/ || true
|
|
exit 1
|
|
fi
|
|
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
|
|
|
|
- name: Install OpenAI + Anthropic Python SDKs
|
|
run: python -m pip install 'openai>=1.50' 'anthropic>=0.40'
|
|
|
|
- name: Reset auth + boot Studio (API-only)
|
|
run: |
|
|
unsloth studio reset-password
|
|
mkdir -p logs
|
|
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
|
> logs/studio.log 2>&1 &
|
|
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
|
|
|
- name: Wait for /api/health
|
|
run: |
|
|
for i in $(seq 1 180); do
|
|
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
|
jq -e '.status == "healthy"' /tmp/health.json
|
|
exit 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
echo "Studio did not become healthy in 180s"
|
|
tail -200 logs/studio.log
|
|
exit 1
|
|
|
|
- name: Password rotation (old must fail, new must work)
|
|
run: |
|
|
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
|
NEW="CIRotated-$(python -c 'import secrets; print(secrets.token_urlsafe(12))')"
|
|
echo "::add-mask::$OLD"
|
|
echo "::add-mask::$NEW"
|
|
OLD_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
|
-H 'content-type: application/json' \
|
|
-d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}" | jq -r .access_token)
|
|
[ -n "$OLD_TOKEN" ] && [ "$OLD_TOKEN" != "null" ] || { echo "bootstrap login failed"; exit 1; }
|
|
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \
|
|
-H "Authorization: Bearer $OLD_TOKEN" -H 'content-type: application/json' \
|
|
-d "{\"current_password\":\"$OLD\",\"new_password\":\"$NEW\"}" > /dev/null
|
|
OLD_STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
-X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
|
-H 'content-type: application/json' \
|
|
-d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}")
|
|
if [ "$OLD_STATUS" != "401" ]; then
|
|
echo "::error::Login with old password returned $OLD_STATUS, expected 401"
|
|
exit 1
|
|
fi
|
|
NEW_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
|
-H 'content-type: application/json' \
|
|
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
|
|
[ -n "$NEW_TOKEN" ] && [ "$NEW_TOKEN" != "null" ] || { echo "new login failed"; exit 1; }
|
|
echo "TOKEN=$NEW_TOKEN" >> "$GITHUB_ENV"
|
|
echo "password rotation OK (old=401, new=200)"
|
|
|
|
- name: Load the GGUF (HF repo + variant, served from HF_HOME cache)
|
|
run: |
|
|
# 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:
|
|
BASE_URL: http://127.0.0.1:18888
|
|
run: |
|
|
python - <<'PY'
|
|
import json
|
|
import os
|
|
from openai import OpenAI
|
|
from anthropic import Anthropic
|
|
|
|
BASE = os.environ["BASE_URL"]
|
|
KEY = os.environ["TOKEN"]
|
|
SEED = 3407
|
|
|
|
PROMPTS = [
|
|
"What is 1+1?",
|
|
"What did I ask before?",
|
|
"What is the capital of France?",
|
|
"Repeat the city name",
|
|
]
|
|
|
|
def run_openai():
|
|
client = OpenAI(base_url = f"{BASE}/v1", api_key = KEY)
|
|
history, replies = [], []
|
|
for prompt in PROMPTS:
|
|
history.append({"role": "user", "content": prompt})
|
|
resp = client.chat.completions.create(
|
|
model = "default",
|
|
messages = history,
|
|
temperature = 0.0,
|
|
max_tokens = 80,
|
|
seed = SEED,
|
|
extra_body = {"enable_thinking": False},
|
|
)
|
|
text = resp.choices[0].message.content or ""
|
|
replies.append(text)
|
|
history.append({"role": "assistant", "content": text})
|
|
return replies
|
|
|
|
def run_anthropic():
|
|
client = Anthropic(
|
|
base_url = BASE,
|
|
api_key = "unused",
|
|
default_headers = {"Authorization": f"Bearer {KEY}"},
|
|
)
|
|
history, replies = [], []
|
|
for prompt in PROMPTS:
|
|
history.append({"role": "user", "content": prompt})
|
|
msg = client.messages.create(
|
|
model = "default",
|
|
max_tokens = 80,
|
|
messages = history,
|
|
temperature = 0.0,
|
|
extra_body = {"seed": SEED, "enable_thinking": False},
|
|
)
|
|
text = "".join(b.text for b in msg.content if getattr(b, "type", None) == "text")
|
|
replies.append(text)
|
|
history.append({"role": "assistant", "content": text})
|
|
return replies
|
|
|
|
for label, runner in (("openai", run_openai), ("anthropic", run_anthropic)):
|
|
first = runner()
|
|
second = runner()
|
|
for i, (a, b) in enumerate(zip(first, second), start = 1):
|
|
print(f"[{label} turn {i}] {a!r}")
|
|
assert a, f"{label}: empty turn {i} response"
|
|
# Compare on stripped content: llama-server can vary
|
|
# trailing whitespace (specifically a final '\n') between
|
|
# otherwise-identical greedy runs depending on the
|
|
# batch-flush boundary at which the stream is closed. The
|
|
# generated tokens are identical; only the trailing
|
|
# whitespace differs. Keep the raw repr in the failure
|
|
# message so a real divergence is still legible.
|
|
assert a.strip() == b.strip(), (
|
|
f"{label} non-deterministic at turn {i} with temperature=0.0:\n"
|
|
f" run1: {a!r}\n run2: {b!r}"
|
|
)
|
|
joined = " ".join(first).lower()
|
|
assert "1" in first[0], f"{label}: turn-1 answer should contain '1', got {first[0]!r}"
|
|
assert "paris" in joined, f"{label}: expected 'paris' somewhere in the four-turn transcript: {first}"
|
|
print(f"[{label}] OK -- 4 turns, run1 == run2, history grounded")
|
|
PY
|
|
|
|
- name: Stop Studio
|
|
if: always()
|
|
# Run as cmd so we are not running through the Git Bash shell;
|
|
# Git Bash on windows-latest has been observed to exit 143
|
|
# (SIGTERM) from any inline kill/sleep block, masking a green
|
|
# test run. The runner reclaims the Studio child process at
|
|
# job end either way, so just emit a marker and exit 0.
|
|
shell: cmd
|
|
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
|
|
|
|
- name: Collect llama-server logs
|
|
if: always()
|
|
# A transient Windows DLL-init crash (0xC0000142) in this diagnostic
|
|
# copy must not fail an otherwise-green job.
|
|
continue-on-error: true
|
|
shell: bash
|
|
# Copy llama-server's own stdout/stderr (teed by Studio under
|
|
# ~/.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()
|
|
# Diagnostic only: a transient artifact-service drop must not fail a green job.
|
|
continue-on-error: true
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: windows-openai-anthropic-log
|
|
path: |
|
|
logs/studio.log
|
|
logs/install.log
|
|
logs/llama-server/*.log
|
|
retention-days: 7
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Job 2: Tool calling Tests
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
tool-calling:
|
|
name: Tool calling Tests
|
|
runs-on: windows-latest
|
|
timeout-minutes: 30
|
|
defaults:
|
|
run:
|
|
shell: bash
|
|
env:
|
|
# Tool calling is the highest-volume GGUF in this workflow
|
|
# (Qwen3.5-2B at Q4_K_XL = ~1.28 GiB). The previous HF_HOME
|
|
# cache stored xet chunks + blobs + snapshots = ~4.7 GiB --
|
|
# 3.7x file-size inflation, dominating the post-step upload
|
|
# (211 s on first run; subsequent runs hit the cache, but the
|
|
# one-time cost recurs every time the cache key bumps). Use
|
|
# main's `--local-dir gguf-cache` pattern: cache the flat .gguf
|
|
# only, pass an absolute path to Studio's /api/inference/load.
|
|
# The OpenAI/Anth and JSON+images jobs still cover the
|
|
# gguf_variant resolution path.
|
|
GGUF_REPO: unsloth/Qwen3.5-2B-GGUF
|
|
GGUF_FILE: Qwen3.5-2B-UD-Q4_K_XL.gguf
|
|
STUDIO_PORT: '18898'
|
|
# Force UTF-8 for stdio (Windows defaults to cp1252; hf
|
|
# download / Studio CLI print "✓" checkmarks and crash
|
|
# otherwise).
|
|
PYTHONIOENCODING: utf-8
|
|
PYTHONUTF8: '1'
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: '22'
|
|
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
|
with:
|
|
python-version: '3.12'
|
|
|
|
# Split restore + save so a transient restore-side failure does not
|
|
# kill the whole job. See the matching block in the tool-calling job
|
|
# above for the full rationale (actions/cache#1621).
|
|
- name: Restore GGUF model cache
|
|
id: cache-gguf
|
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
continue-on-error: true
|
|
with:
|
|
path: gguf-cache
|
|
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
|
|
|
- name: Download GGUF if cache miss
|
|
id: download-gguf
|
|
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
|
|
env:
|
|
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
|
run: |
|
|
python -m pip install --upgrade huggingface_hub
|
|
mkdir -p gguf-cache
|
|
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache
|
|
|
|
- name: Save GGUF model cache
|
|
if: always() && steps.download-gguf.outcome == 'success'
|
|
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
with:
|
|
path: gguf-cache
|
|
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
|
|
|
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
|
|
shell: pwsh
|
|
# See studio-windows-update-smoke.yml for the full rationale.
|
|
# tl;dr: setup.ps1 needs npm >=11 to skip a 35 s winget Node
|
|
# reinstall, and Defender's real-time scan dominates the
|
|
# frontend / uv-pip-extract steps.
|
|
run: |
|
|
$ProgressPreference = 'SilentlyContinue'
|
|
Write-Host "npm version before upgrade: $(npm -v)"
|
|
npm install -g 'npm@^11' 2>&1 | Out-Host
|
|
Write-Host "npm version after upgrade: $(npm -v)"
|
|
# NOTE: do NOT pre-create these directories. See
|
|
# studio-windows-update-smoke.yml for the full rationale --
|
|
# creating an empty studio/frontend/dist trips setup.ps1's
|
|
# mtime-based staleness check into "frontend up to date, skip
|
|
# rebuild" and Studio boots with an empty dist directory.
|
|
# Add-MpPreference accepts paths that do not yet exist.
|
|
foreach ($p in @(
|
|
"$env:USERPROFILE\.unsloth",
|
|
"$env:USERPROFILE\AppData\Local\uv",
|
|
"$env:GITHUB_WORKSPACE\studio\frontend\node_modules",
|
|
"$env:GITHUB_WORKSPACE\studio\frontend\dist"
|
|
)) {
|
|
try {
|
|
Add-MpPreference -ExclusionPath $p -ErrorAction Stop
|
|
Write-Host "Defender exclusion added: $p"
|
|
} catch {
|
|
Write-Host "Defender exclusion skipped ($($_.Exception.Message)): $p"
|
|
}
|
|
}
|
|
|
|
- name: Install Studio (--local, --no-torch)
|
|
shell: pwsh
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
|
run: |
|
|
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
|
# *>&1 captures Write-Host (Information stream) output;
|
|
# plain 2>&1 does not. setup.ps1 emits "prebuilt installed
|
|
# and validated" via Write-Host, and we grep for that.
|
|
$ProgressPreference = 'SilentlyContinue'
|
|
& ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log
|
|
|
|
- name: Assert install.ps1 used the Windows llama.cpp prebuilt
|
|
run: |
|
|
# Filesystem check; setup.ps1's stream output isn't captured.
|
|
LLAMA_DIR=~/.unsloth/llama.cpp
|
|
INFO="$LLAMA_DIR/UNSLOTH_PREBUILT_INFO.json"
|
|
BIN="$LLAMA_DIR/build/bin/Release/llama-server.exe"
|
|
if grep -q "falling back to source build" logs/install.log; then
|
|
echo "::error::install.ps1 fell back to source-build llama.cpp on Windows."
|
|
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
|
exit 1
|
|
fi
|
|
if [ ! -f "$INFO" ]; then
|
|
echo "::error::no UNSLOTH_PREBUILT_INFO.json at $INFO."
|
|
ls -la "$LLAMA_DIR" || true
|
|
exit 1
|
|
fi
|
|
if [ ! -f "$BIN" ]; then
|
|
echo "::error::no llama-server.exe at $BIN."
|
|
ls -la "$LLAMA_DIR/build/bin" || true
|
|
exit 1
|
|
fi
|
|
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
|
|
cat "$INFO"
|
|
|
|
- name: Add Studio shim to GITHUB_PATH
|
|
run: |
|
|
SHIM_DIR=~/.unsloth/studio/bin
|
|
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
|
|
echo "::error::unsloth.exe shim not found at $SHIM_DIR"
|
|
ls -la ~/.unsloth/studio/ || true
|
|
exit 1
|
|
fi
|
|
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
|
|
|
|
- name: Reset auth + boot Studio (API-only, default tool policy)
|
|
run: |
|
|
unsloth studio reset-password
|
|
mkdir -p logs
|
|
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
|
> logs/studio.log 2>&1 &
|
|
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
|
|
|
- name: Wait for /api/health, log in, change password, load model
|
|
run: |
|
|
for i in $(seq 1 180); do
|
|
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
|
jq -e '.status == "healthy"' /tmp/health.json && break
|
|
fi
|
|
sleep 1
|
|
done
|
|
jq -e '.status == "healthy"' /tmp/health.json
|
|
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
|
NEW="CITool-$(python -c 'import secrets; print(secrets.token_urlsafe(12))')"
|
|
echo "::add-mask::$OLD"
|
|
echo "::add-mask::$NEW"
|
|
OLD_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
|
-H 'content-type: application/json' \
|
|
-d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}" | jq -r .access_token)
|
|
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \
|
|
-H "Authorization: Bearer $OLD_TOKEN" -H 'content-type: application/json' \
|
|
-d "{\"current_password\":\"$OLD\",\"new_password\":\"$NEW\"}" > /dev/null
|
|
TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
|
-H 'content-type: application/json' \
|
|
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
|
|
echo "API_KEY=$TOKEN" >> "$GITHUB_ENV"
|
|
# GITHUB_WORKSPACE on windows-latest is a Windows path with
|
|
# backslashes ("D:\a\unsloth\unsloth"). Bash handles it as a
|
|
# raw string, but we cannot embed `\a` etc. in JSON without
|
|
# JSON-string-escaping every backslash. Replace `\` with `/`
|
|
# via bash parameter expansion -- pathlib.Path on Windows
|
|
# accepts forward slashes natively, so Studio's loader sees
|
|
# a normal path.
|
|
GGUF_PATH="${GITHUB_WORKSPACE//\\//}/gguf-cache/${GGUF_FILE}"
|
|
ls -lh "$GGUF_PATH"
|
|
# 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:
|
|
BASE_URL: http://127.0.0.1:18898
|
|
run: |
|
|
python - <<'PY'
|
|
import json
|
|
import os
|
|
import urllib.request
|
|
|
|
BASE = os.environ["BASE_URL"]
|
|
KEY = os.environ["API_KEY"]
|
|
SEED = 3407
|
|
# Same temperature shim as the Mac job. Small Qwen3.5-2B
|
|
# quants can degenerate at temperature=0; a small non-zero
|
|
# temperature with a fixed seed keeps the test deterministic
|
|
# while escaping the trap.
|
|
TEMP = 0.2
|
|
|
|
def post(path, body, *, timeout = 240):
|
|
data = json.dumps(body).encode()
|
|
req = urllib.request.Request(
|
|
f"{BASE}{path}",
|
|
data = data,
|
|
method = "POST",
|
|
headers = {
|
|
"Authorization": f"Bearer {KEY}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
)
|
|
with urllib.request.urlopen(req, timeout = timeout) as resp:
|
|
return resp.status, json.loads(resp.read().decode())
|
|
|
|
def post_sse(path, body, *, timeout = 600):
|
|
body = {**body, "stream": True}
|
|
data = json.dumps(body).encode()
|
|
req = urllib.request.Request(
|
|
f"{BASE}{path}",
|
|
data = data,
|
|
method = "POST",
|
|
headers = {
|
|
"Authorization": f"Bearer {KEY}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
)
|
|
parts = []
|
|
with urllib.request.urlopen(req, timeout = timeout) as resp:
|
|
for raw in resp:
|
|
line = raw.decode().strip()
|
|
if not line.startswith("data: "):
|
|
continue
|
|
payload = line[6:]
|
|
if payload == "[DONE]":
|
|
break
|
|
try:
|
|
chunk = json.loads(payload)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
for choice in chunk.get("choices", []):
|
|
delta = choice.get("delta", {}) or {}
|
|
if delta.get("content"):
|
|
parts.append(delta["content"])
|
|
return "".join(parts)
|
|
|
|
# ── 1. Standard OpenAI function calling ──────────────────────
|
|
weather_tool = {
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"description": "Get current weather for a city.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"city": {"type": "string"}},
|
|
"required": ["city"],
|
|
},
|
|
},
|
|
}
|
|
|
|
status, data = post("/v1/chat/completions", {
|
|
"messages": [{"role": "user", "content": "What is the weather in Paris?"}],
|
|
"tools": [weather_tool],
|
|
"tool_choice": "required",
|
|
"stream": False,
|
|
"temperature": TEMP,
|
|
"seed": SEED,
|
|
"max_tokens": 600,
|
|
})
|
|
assert status == 200, f"tool call status {status}: {data}"
|
|
choice = data["choices"][0]
|
|
tool_calls = (choice.get("message") or {}).get("tool_calls") or []
|
|
if tool_calls:
|
|
tc = tool_calls[0]
|
|
assert tc["function"]["name"] == "get_weather", (
|
|
f"unexpected tool name: {tc['function']['name']!r}"
|
|
)
|
|
args = json.loads(tc["function"]["arguments"])
|
|
assert args.get("city"), f"missing city arg: {args}"
|
|
print(f"[tools] PASS function calling -> {tc['function']['name']}({args}) finish={choice.get('finish_reason')!r}")
|
|
else:
|
|
print(
|
|
f"[tools] WARN function calling: no tool_calls (finish_reason="
|
|
f"{choice.get('finish_reason')!r}); HTTP path OK, model output drift."
|
|
)
|
|
|
|
# ── 2. Server-side python tool ───────────────────────────────
|
|
content = post_sse("/v1/chat/completions", {
|
|
"messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}],
|
|
"enable_tools": True,
|
|
"enabled_tools": ["python"],
|
|
"session_id": "ci-tool-calling-py",
|
|
"temperature": TEMP,
|
|
"seed": SEED,
|
|
"max_tokens": 600,
|
|
})
|
|
if "56088" in content or "56,088" in content:
|
|
print(f"[tools] PASS python tool ({len(content)} chars, found 56088)")
|
|
else:
|
|
assert content, "python tool: SSE stream empty"
|
|
print(
|
|
f"[tools] WARN python tool: SSE OK ({len(content)} chars) but "
|
|
f"model didn't return 56088 -- model output drift"
|
|
)
|
|
|
|
# ── 3. Server-side bash (terminal) tool ──────────────────────
|
|
# On Windows the terminal tool resolves to the system shell
|
|
# (cmd.exe wrapper) and `echo hello-bash-tool` works the same
|
|
# way it does on POSIX. The model still has to choose to
|
|
# invoke the tool; assert non-empty SSE if it doesn't.
|
|
content = post_sse("/v1/chat/completions", {
|
|
"messages": [{"role": "user", "content": "Use the terminal tool to run `echo hello-bash-tool` and tell me the exact output."}],
|
|
"enable_tools": True,
|
|
"enabled_tools": ["terminal"],
|
|
"session_id": "ci-tool-calling-bash",
|
|
"temperature": TEMP,
|
|
"seed": SEED,
|
|
"max_tokens": 600,
|
|
})
|
|
if "hello-bash-tool" in content:
|
|
print(f"[tools] PASS terminal tool ({len(content)} chars)")
|
|
else:
|
|
assert content, "terminal tool: SSE stream empty"
|
|
print(
|
|
f"[tools] WARN terminal tool: SSE OK ({len(content)} chars) but "
|
|
f"model didn't echo 'hello-bash-tool' -- model output drift"
|
|
)
|
|
|
|
# ── 4. Server-side web_search tool ───────────────────────────
|
|
# DuckDuckGo can be flaky from CI runners; only assert that
|
|
# the SSE stream opens and yields any data.
|
|
try:
|
|
content = post_sse("/v1/chat/completions", {
|
|
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
|
|
"enable_tools": True,
|
|
"enabled_tools": ["web_search"],
|
|
"session_id": "ci-tool-calling-web",
|
|
"temperature": TEMP,
|
|
"seed": SEED,
|
|
"max_tokens": 400,
|
|
})
|
|
print(f"[tools] PASS web_search stream ({len(content)} chars)")
|
|
except Exception as exc:
|
|
print(f"[tools] WARN web_search probe failed (non-blocking): {exc}")
|
|
|
|
# ── 5. Thinking on / off ─────────────────────────────────────
|
|
def thinking_call(enable):
|
|
status, data = post("/v1/chat/completions", {
|
|
"messages": [{"role": "user", "content": "Briefly: is 17 prime?"}],
|
|
"stream": False,
|
|
"enable_thinking": enable,
|
|
"temperature": TEMP,
|
|
"seed": SEED,
|
|
"max_tokens": 300,
|
|
})
|
|
assert status == 200
|
|
msg = data["choices"][0]["message"]
|
|
raw = (msg.get("content") or "") + (msg.get("reasoning_content") or "")
|
|
return raw
|
|
|
|
on_text = thinking_call(True)
|
|
off_text = thinking_call(False)
|
|
had_think_on = ("<think>" in on_text) or len(on_text) > 80
|
|
if not had_think_on:
|
|
print(
|
|
f"[tools] WARN enable_thinking=True produced no thinking signal: "
|
|
f"{on_text[:200]!r}"
|
|
)
|
|
assert "<think>" not in off_text, (
|
|
f"enable_thinking=False but <think> still present: {off_text!r}"
|
|
)
|
|
print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)")
|
|
PY
|
|
|
|
- name: Stop Studio
|
|
if: always()
|
|
# Run as cmd so we are not running through the Git Bash shell;
|
|
# Git Bash on windows-latest has been observed to exit 143
|
|
# (SIGTERM) from any inline kill/sleep block, masking a green
|
|
# test run. The runner reclaims the Studio child process at
|
|
# job end either way, so just emit a marker and exit 0.
|
|
shell: cmd
|
|
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
|
|
|
|
- name: Collect llama-server logs
|
|
if: always()
|
|
# A transient Windows DLL-init crash (0xC0000142) in this diagnostic
|
|
# copy must not fail an otherwise-green job.
|
|
continue-on-error: true
|
|
shell: bash
|
|
# Copy llama-server's own stdout/stderr (teed by Studio under
|
|
# ~/.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()
|
|
# Diagnostic only: a transient artifact-service drop must not fail a green job.
|
|
continue-on-error: true
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: windows-tool-calling-log
|
|
path: |
|
|
logs/studio.log
|
|
logs/install.log
|
|
logs/llama-server/*.log
|
|
retention-days: 7
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Job 3: JSON, images
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
json-images:
|
|
name: JSON, images
|
|
runs-on: windows-latest
|
|
timeout-minutes: 35
|
|
defaults:
|
|
run:
|
|
shell: bash
|
|
env:
|
|
GGUF_REPO: unsloth/Qwen3-VL-2B-Instruct-GGUF
|
|
GGUF_VARIANT: UD-IQ2_XXS
|
|
GGUF_FILE: Qwen3-VL-2B-Instruct-UD-IQ2_XXS.gguf
|
|
MMPROJ_FILE: mmproj-F16.gguf
|
|
STUDIO_PORT: '18899'
|
|
HF_HOME: ${{ github.workspace }}/hf-cache
|
|
# Force UTF-8 for stdio (Windows defaults to cp1252; hf
|
|
# download / Studio CLI print "✓" checkmarks and crash
|
|
# otherwise).
|
|
PYTHONIOENCODING: utf-8
|
|
PYTHONUTF8: '1'
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: '22'
|
|
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
|
with:
|
|
python-version: '3.12'
|
|
|
|
# Split restore + save so a transient restore-side failure does not
|
|
# kill the whole job. See the matching block in the tool-calling job
|
|
# for the full rationale (actions/cache#1621). This is the block that
|
|
# actually broke in run 25713577488: "Cache hit for: <key>" was
|
|
# logged, the step exited non-zero in ~0.3 s without extracting the
|
|
# 3.4 GiB archive, and steps 6-15 were skipped.
|
|
- name: Restore HF_HOME cache for ${{ env.GGUF_REPO }} (model + mmproj)
|
|
id: cache-hf
|
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
continue-on-error: true
|
|
with:
|
|
path: hf-cache
|
|
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v2
|
|
|
|
- name: Prime HF_HOME with the GGUF + mmproj
|
|
id: prime-hf
|
|
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
|
env:
|
|
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
|
run: |
|
|
python -m pip install --upgrade huggingface_hub
|
|
mkdir -p hf-cache
|
|
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
|
|
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$MMPROJ_FILE"
|
|
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
|
|
|
|
- name: Save HF_HOME cache for ${{ env.GGUF_REPO }} (model + mmproj)
|
|
if: always() && steps.prime-hf.outcome == 'success'
|
|
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
with:
|
|
path: hf-cache
|
|
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v2
|
|
|
|
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
|
|
shell: pwsh
|
|
# See studio-windows-update-smoke.yml for the full rationale.
|
|
# tl;dr: setup.ps1 needs npm >=11 to skip a 35 s winget Node
|
|
# reinstall, and Defender's real-time scan dominates the
|
|
# frontend / uv-pip-extract steps.
|
|
run: |
|
|
$ProgressPreference = 'SilentlyContinue'
|
|
Write-Host "npm version before upgrade: $(npm -v)"
|
|
npm install -g 'npm@^11' 2>&1 | Out-Host
|
|
Write-Host "npm version after upgrade: $(npm -v)"
|
|
# NOTE: do NOT pre-create these directories. See
|
|
# studio-windows-update-smoke.yml for the full rationale --
|
|
# creating an empty studio/frontend/dist trips setup.ps1's
|
|
# mtime-based staleness check into "frontend up to date, skip
|
|
# rebuild" and Studio boots with an empty dist directory.
|
|
# Add-MpPreference accepts paths that do not yet exist.
|
|
foreach ($p in @(
|
|
"$env:USERPROFILE\.unsloth",
|
|
"$env:USERPROFILE\AppData\Local\uv",
|
|
"$env:GITHUB_WORKSPACE\studio\frontend\node_modules",
|
|
"$env:GITHUB_WORKSPACE\studio\frontend\dist"
|
|
)) {
|
|
try {
|
|
Add-MpPreference -ExclusionPath $p -ErrorAction Stop
|
|
Write-Host "Defender exclusion added: $p"
|
|
} catch {
|
|
Write-Host "Defender exclusion skipped ($($_.Exception.Message)): $p"
|
|
}
|
|
}
|
|
|
|
- name: Install Studio (--local, --no-torch)
|
|
shell: pwsh
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
|
run: |
|
|
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
|
# *>&1 captures Write-Host (Information stream) output;
|
|
# plain 2>&1 does not. setup.ps1 emits "prebuilt installed
|
|
# and validated" via Write-Host, and we grep for that.
|
|
$ProgressPreference = 'SilentlyContinue'
|
|
& ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log
|
|
|
|
- name: Assert install.ps1 used the Windows llama.cpp prebuilt
|
|
run: |
|
|
# Filesystem check; setup.ps1's stream output isn't captured.
|
|
LLAMA_DIR=~/.unsloth/llama.cpp
|
|
INFO="$LLAMA_DIR/UNSLOTH_PREBUILT_INFO.json"
|
|
BIN="$LLAMA_DIR/build/bin/Release/llama-server.exe"
|
|
if grep -q "falling back to source build" logs/install.log; then
|
|
echo "::error::install.ps1 fell back to source-build llama.cpp on Windows."
|
|
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
|
exit 1
|
|
fi
|
|
if [ ! -f "$INFO" ]; then
|
|
echo "::error::no UNSLOTH_PREBUILT_INFO.json at $INFO."
|
|
ls -la "$LLAMA_DIR" || true
|
|
exit 1
|
|
fi
|
|
if [ ! -f "$BIN" ]; then
|
|
echo "::error::no llama-server.exe at $BIN."
|
|
ls -la "$LLAMA_DIR/build/bin" || true
|
|
exit 1
|
|
fi
|
|
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
|
|
cat "$INFO"
|
|
|
|
- name: Add Studio shim to GITHUB_PATH
|
|
run: |
|
|
SHIM_DIR=~/.unsloth/studio/bin
|
|
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
|
|
echo "::error::unsloth.exe shim not found at $SHIM_DIR"
|
|
ls -la ~/.unsloth/studio/ || true
|
|
exit 1
|
|
fi
|
|
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
|
|
|
|
- name: Install OpenAI + Anthropic Python SDKs
|
|
run: python -m pip install 'openai>=1.50' 'anthropic>=0.40'
|
|
|
|
- name: Reset auth + boot Studio (API-only)
|
|
run: |
|
|
unsloth studio reset-password
|
|
mkdir -p logs
|
|
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
|
> logs/studio.log 2>&1 &
|
|
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
|
|
|
- name: Wait for /api/health, log in, change password, load model
|
|
run: |
|
|
for i in $(seq 1 180); do
|
|
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
|
jq -e '.status == "healthy"' /tmp/health.json && break
|
|
fi
|
|
sleep 1
|
|
done
|
|
jq -e '.status == "healthy"' /tmp/health.json
|
|
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
|
NEW="CIJson-$(python -c 'import secrets; print(secrets.token_urlsafe(12))')"
|
|
echo "::add-mask::$OLD"
|
|
echo "::add-mask::$NEW"
|
|
OLD_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
|
-H 'content-type: application/json' \
|
|
-d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}" | jq -r .access_token)
|
|
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \
|
|
-H "Authorization: Bearer $OLD_TOKEN" -H 'content-type: application/json' \
|
|
-d "{\"current_password\":\"$OLD\",\"new_password\":\"$NEW\"}" > /dev/null
|
|
TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
|
-H 'content-type: application/json' \
|
|
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
|
|
echo "API_KEY=$TOKEN" >> "$GITHUB_ENV"
|
|
# 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:
|
|
BASE_URL: http://127.0.0.1:18899
|
|
run: |
|
|
python - <<'PY'
|
|
import base64
|
|
import json
|
|
import os
|
|
import urllib.request
|
|
from openai import OpenAI
|
|
from anthropic import Anthropic
|
|
|
|
BASE = os.environ["BASE_URL"]
|
|
KEY = os.environ["API_KEY"]
|
|
SEED = 3407
|
|
TEMP = 0.2
|
|
|
|
def post(path, body, *, timeout = 240):
|
|
req = urllib.request.Request(
|
|
f"{BASE}{path}",
|
|
data = json.dumps(body).encode(),
|
|
method = "POST",
|
|
headers = {
|
|
"Authorization": f"Bearer {KEY}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
)
|
|
with urllib.request.urlopen(req, timeout = timeout) as resp:
|
|
return resp.status, json.loads(resp.read().decode())
|
|
|
|
# ── 1. response_format = json_object (JSON mode) ─────────────
|
|
status, data = post("/v1/chat/completions", {
|
|
"model": "default",
|
|
"messages": [
|
|
{"role": "system", "content": 'Reply with a single JSON object of the form {"city": "...", "country": "..."}. Output ONLY the JSON, nothing else.'},
|
|
{"role": "user", "content": "What is the capital of France?"},
|
|
],
|
|
"temperature": TEMP,
|
|
"max_tokens": 600,
|
|
"seed": SEED,
|
|
"stream": False,
|
|
"enable_thinking": False,
|
|
"response_format": {"type": "json_object"},
|
|
}, timeout = 600)
|
|
assert status == 200, f"json status {status}: {data}"
|
|
assert (
|
|
isinstance(data.get("choices"), list)
|
|
and data["choices"]
|
|
and "message" in data["choices"][0]
|
|
), f"json response envelope malformed: {data}"
|
|
content = (data["choices"][0]["message"].get("content") or "").strip()
|
|
print(f"[json] raw json_object content: {content!r}")
|
|
if content.startswith("```"):
|
|
content = content.split("```", 2)[1]
|
|
if content.startswith("json"):
|
|
content = content[4:]
|
|
content = content.strip("`\n ")
|
|
if content:
|
|
try:
|
|
parsed = json.loads(content)
|
|
if "paris" in str(parsed.get("city", "")).lower():
|
|
print(f"[json] PASS json_object -> {parsed}")
|
|
else:
|
|
print(f"[json] WARN json_object decoded but city!=Paris: {parsed}")
|
|
except json.JSONDecodeError as exc:
|
|
print(f"[json] WARN json_object content not parseable ({exc}); content={content!r}")
|
|
else:
|
|
print("[json] WARN json_object produced empty content")
|
|
|
|
status2, data2 = post("/v1/chat/completions", {
|
|
"model": "default",
|
|
"messages": [{"role": "user", "content": "What is the capital of France? Answer with one word."}],
|
|
"temperature": TEMP,
|
|
"max_tokens": 400,
|
|
"seed": SEED,
|
|
"stream": False,
|
|
"enable_thinking": False,
|
|
}, timeout = 600)
|
|
assert status2 == 200, f"plain status {status2}: {data2}"
|
|
plain = (data2["choices"][0]["message"].get("content") or "").lower()
|
|
print(f"[json] plain capital-of-france reply: {plain!r}")
|
|
if "paris" in plain:
|
|
print("[json] PASS plain inference path (paris mentioned)")
|
|
else:
|
|
print(
|
|
f"[json] WARN plain inference returned no 'paris' -- "
|
|
f"model output drift. HTTP path validated separately above."
|
|
)
|
|
|
|
# ── 2. OpenAI image_url (data URI base64) ───────────────────
|
|
PNG_64X64_RED_B64 = (
|
|
"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAYklEQVR4nO3PMQ0AIADAMEAI/k"
|
|
"UhBhEcDcmqYJtn7/GzpQNeNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA"
|
|
"1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaBdCJ0BmMJ25zMAAAAASUVORK5CYII="
|
|
)
|
|
data_uri = f"data:image/png;base64,{PNG_64X64_RED_B64}"
|
|
|
|
# On Windows + the Qwen3-VL mmproj, llama.cpp's vision
|
|
# path runs on CPU (no Metal involvement). The wrapper is
|
|
# kept for resilience but the vision path is expected to
|
|
# work on Windows; an exception here is a real regression.
|
|
client = OpenAI(base_url = f"{BASE}/v1", api_key = KEY)
|
|
try:
|
|
openai_resp = client.chat.completions.create(
|
|
model = "default",
|
|
temperature = TEMP,
|
|
max_tokens = 80,
|
|
seed = SEED,
|
|
messages = [{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "image_url", "image_url": {"url": data_uri}},
|
|
{"type": "text", "text": "What colour dominates this image? Reply in one word."},
|
|
],
|
|
}],
|
|
)
|
|
openai_text = (openai_resp.choices[0].message.content or "").lower()
|
|
print(f"[image/openai] reply: {openai_text!r}")
|
|
if openai_text:
|
|
print("[image/openai] PASS image_url accepted, non-empty response")
|
|
else:
|
|
print("[image/openai] WARN image_url accepted but empty content")
|
|
except Exception as exc:
|
|
print(
|
|
f"[image/openai] WARN image_url SDK call raised: {type(exc).__name__}: "
|
|
f"{exc}. Studio successfully forwarded the request; failure here is "
|
|
f"upstream llama.cpp vision behaviour."
|
|
)
|
|
|
|
# ── 3. Anthropic source/base64 image ────────────────────────
|
|
anthropic = Anthropic(
|
|
base_url = BASE,
|
|
api_key = "unused",
|
|
default_headers = {"Authorization": f"Bearer {KEY}"},
|
|
)
|
|
try:
|
|
a_msg = anthropic.messages.create(
|
|
model = "default",
|
|
max_tokens = 80,
|
|
temperature = TEMP,
|
|
extra_body = {"seed": SEED},
|
|
messages = [{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "image",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": "image/png",
|
|
"data": PNG_64X64_RED_B64,
|
|
},
|
|
},
|
|
{"type": "text", "text": "Describe this image briefly."},
|
|
],
|
|
}],
|
|
)
|
|
a_text = "".join(b.text for b in a_msg.content if getattr(b, "type", None) == "text")
|
|
print(f"[image/anthropic] reply: {a_text!r}")
|
|
if a_text:
|
|
print("[image/anthropic] PASS source/base64 accepted, non-empty response")
|
|
else:
|
|
print("[image/anthropic] WARN source/base64 accepted but empty content")
|
|
except Exception as exc:
|
|
print(
|
|
f"[image/anthropic] WARN anthropic image SDK call raised: "
|
|
f"{type(exc).__name__}: {exc}. Likely upstream llama.cpp vision "
|
|
f"behaviour, NOT a Studio regression."
|
|
)
|
|
PY
|
|
|
|
- name: Stop Studio
|
|
if: always()
|
|
# Run as cmd so we are not running through the Git Bash shell;
|
|
# Git Bash on windows-latest has been observed to exit 143
|
|
# (SIGTERM) from any inline kill/sleep block, masking a green
|
|
# test run. The runner reclaims the Studio child process at
|
|
# job end either way, so just emit a marker and exit 0.
|
|
shell: cmd
|
|
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
|
|
|
|
- name: Collect llama-server logs
|
|
if: always()
|
|
# A transient Windows DLL-init crash (0xC0000142) in this diagnostic
|
|
# copy must not fail an otherwise-green job.
|
|
continue-on-error: true
|
|
shell: bash
|
|
# Copy llama-server's own stdout/stderr (teed by Studio under
|
|
# ~/.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()
|
|
# Diagnostic only: a transient artifact-service drop must not fail a green job.
|
|
continue-on-error: true
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: windows-json-images-log
|
|
path: |
|
|
logs/studio.log
|
|
logs/install.log
|
|
logs/llama-server/*.log
|
|
retention-days: 7
|