Merge branch 'main' into image-generation

This commit is contained in:
oobabooga 2026-06-24 14:07:59 -03:00
commit e8d3cfe2b5
140 changed files with 7764 additions and 1360 deletions

View file

@ -1,8 +0,0 @@
# Commits listed here are skipped by `git blame` so that bulk, whitespace-only
# changes don't obscure the real authorship of a line.
#
# GitHub honors this file automatically. To use it locally, run once:
# git config blame.ignoreRevsFile .git-blame-ignore-revs
# chore(studio/frontend): normalize line endings to LF
c50b8ab910f5aa56dd7ae0022d2c7b96bfe3384a

View file

@ -2204,12 +2204,13 @@ jobs:
pip install -e "$RUNNER_TEMP/unsloth-zoo" --no-deps
pip show unsloth_zoo
- name: llama.cpp install via unsloth_zoo.llama_cpp + `llama-cli --help` smoke
- name: llama.cpp install via unsloth_zoo.llama_cpp + CLI `--help` smoke
# Exercise the canonical `unsloth_zoo.llama_cpp.install_llama_cpp`
# flow that GGUF export uses at runtime: clone ggml-org/llama.cpp
# into ~/.unsloth/llama.cpp, build the LLAMA_CPP_TARGETS list
# (llama-quantize, llama-cli, llama-mtmd-cli, llama-gguf-split,
# llama-server) via cmake, then run `llama-cli --help`.
# llama-server) via cmake, then run `--help` on whichever CLI
# inference binary the build actually produced.
#
# This replaces the previous "download upstream prebuilt zip"
# approach, which silently exited 0 with the message
@ -2218,6 +2219,18 @@ jobs:
# matched their current asset names). The build path is the same
# one Unsloth users hit in production via `model.save_pretrained_gguf`.
#
# We do NOT hard-require `llama-cli` specifically: upstream
# ggml-org/llama.cpp moved the cli/server/ui targets behind the
# `LLAMA_BUILD_SERVER` cmake option (tools/CMakeLists.txt) and the
# set of binaries that survive a given checkout drifts over time
# (e.g. a recent build root shipped llama-server + llama-quantize
# + llama-diffusion-cli but no llama-cli). The durable contract is
# "install_llama_cpp produced a working CLI inference binary AND a
# working quantizer", so we --help-probe the first of
# llama-cli / llama-mtmd-cli / llama-server that exists. If a
# future llama.cpp restores llama-cli it is first in the list and
# is preferred, so this stays backwards compatible.
#
# Wall-time budget: ~3-5 min cold, dominated by cmake build of
# 5 targets on the runner's 4 cores. Apt-package install is
# handled by `install_llama_cpp` itself via its
@ -2252,8 +2265,9 @@ jobs:
print(f"Build targets: {LLAMA_CPP_TARGETS}")
# install_llama_cpp returns (quantizer_path, converter_script_path).
# The quantizer's directory is the `llama.cpp` install root, which
# also holds llama-cli after build/bin/llama-* gets copied up
# (llama_cpp.py:867-871).
# also holds the CLI inference binaries after build/bin/llama-* gets
# copied up (llama_cpp.py:1450-1454; on Windows they stay in
# build/bin/Release/).
quantizer, converter = install_llama_cpp(print_output=True)
assert quantizer and os.path.exists(quantizer), (
f"install_llama_cpp returned quantizer={quantizer!r} but file missing"
@ -2262,25 +2276,54 @@ jobs:
f"install_llama_cpp returned converter={converter!r} but missing"
)
install_root = os.path.dirname(quantizer)
cli = os.path.join(install_root, "llama-cli")
assert os.path.exists(cli), (
f"llama-cli not found at {cli!r} after build. Build root contents: "
f"{sorted(p for p in os.listdir(install_root) if p.startswith('llama-'))[:20]}"
)
assert os.access(cli, os.X_OK), f"{cli!r} not executable"
# `llama-cli --help` exits non-zero on some builds; the contract
# is that recognizable help text appears on stdout/stderr.
is_windows = sys.platform == "win32"
exe = ".exe" if is_windows else ""
# Search both the copied-up root and the Windows build/bin/Release/
# location the quantizer might already live in.
search_dirs = [install_root]
win_release = os.path.join(install_root, "build", "bin", "Release")
if win_release not in search_dirs:
search_dirs.append(win_release)
# Any of these proves a working llama.cpp CLI inference binary was
# built. Order = preference: llama-cli is canonical (restored first
# if upstream brings it back), then the multimodal CLI, then the
# server (always built whenever cli would be, behind LLAMA_BUILD_SERVER).
cli_names = [f"llama-cli{exe}", f"llama-mtmd-cli{exe}", f"llama-server{exe}"]
cli = None
cli_name = None
for name in cli_names:
for d in search_dirs:
candidate = os.path.join(d, name)
if os.path.exists(candidate) and (is_windows or os.access(candidate, os.X_OK)):
cli, cli_name = candidate, name
break
if cli is not None:
break
if cli is None:
found = []
for d in search_dirs:
if os.path.isdir(d):
found += [p for p in os.listdir(d) if p.startswith("llama-")]
raise AssertionError(
f"No CLI inference binary ({', '.join(cli_names)}) found after "
f"build in {search_dirs}. Build root contents: {sorted(set(found))[:20]}"
)
print(f"Using CLI inference binary: {cli_name} -> {cli}")
# `--help` exits non-zero on some builds; the contract is that
# recognizable help text appears on stdout/stderr. llama-server
# exposes a different flag set than llama-cli, so accept its
# tokens too (e.g. --host / --port / "server").
proc = subprocess.run(
[cli, "--help"], capture_output=True, text=True, timeout=30,
)
combined = (proc.stdout or "") + (proc.stderr or "")
print("--- llama-cli --help (first 30 lines) ---")
print(f"--- {cli_name} --help (first 30 lines) ---")
print("\n".join(combined.splitlines()[:30]))
assert any(
tok in combined.lower()
for tok in ("usage", "--help", "--model", "-m,")
for tok in ("usage", "--help", "--model", "-m,", "--host", "--port", "server")
), (
f"llama-cli --help produced no recognizable help text. "
f"{cli_name} --help produced no recognizable help text. "
f"exit={proc.returncode}\nstdout: {proc.stdout[:400]!r}\n"
f"stderr: {proc.stderr[:400]!r}"
)
@ -2296,7 +2339,7 @@ jobs:
f"stderr: {q.stderr[:400]!r}"
)
print(
f"\nOK: install_llama_cpp produced a working llama-cli at {cli} "
f"\nOK: install_llama_cpp produced a working {cli_name} at {cli} "
f"and llama-quantize at {quantizer}."
)
PY

View file

@ -64,6 +64,12 @@ concurrency:
permissions:
contents: read
# Secret handling on pull_request: these jobs check out and run PR-controlled code
# (install.sh, .github/scripts/**), so HF_TOKEN (an external HF credential) is gated
# off pull_request at each step below -- public GGUF repos still download anonymously.
# GH_TOKEN (GITHUB_TOKEN) is kept: it is the job-scoped contents:read token and
# install_llama_prebuilt.py needs it for the GitHub releases API (else 403s).
env:
# Determinism precedent (studio-inference-smoke.yml): temp 0 + fixed seed.
UNSLOTH_SEED: '3407'
@ -134,7 +140,8 @@ jobs:
id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Gated off PR (see note above); public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p gguf-cache
@ -150,7 +157,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Gated off PR (see note above); public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
@ -335,7 +343,8 @@ jobs:
id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Gated off PR (see note above); public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p gguf-cache
@ -351,7 +360,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Gated off PR (see note above); public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
@ -508,7 +518,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Gated off PR (see note above); public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -524,7 +535,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Gated off PR (see note above); public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail

View file

@ -241,7 +241,8 @@ jobs:
# non-zero binary exit is an Unsloth/Studio bug.
- name: Studio prebuilt llama.cpp install + GGUF inference (Mac M1)
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
# install_llama_prebuilt.py hits the GitHub releases API to
# resolve the asset URL. Anonymous calls share the runner-IP
# rate-limit bucket and 403 quickly -- pass the workflow's
@ -332,7 +333,8 @@ jobs:
# train_metrics.json so we can detect regressions across CI runs.
- name: MLX export round-trip — TRAIN + SAVE 3 formats
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
UNSLOTH_COMPILE_DISABLE: '1'
run: |
mkdir -p mlx_workdir
@ -348,7 +350,8 @@ jobs:
# the saved dir.
- name: MLX export round-trip — RELOAD LoRA (fresh process)
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
UNSLOTH_COMPILE_DISABLE: '1'
run: |
python tests/studio/run_real_mlx_smoke.py reload \
@ -357,7 +360,8 @@ jobs:
- name: MLX export round-trip — RELOAD merged_16bit (fresh process)
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
UNSLOTH_COMPILE_DISABLE: '1'
run: |
python tests/studio/run_real_mlx_smoke.py reload \
@ -372,7 +376,8 @@ jobs:
# LoRA + merged_16bit assertions remain the gating signal.
- name: MLX export round-trip — RELOAD GGUF via llama-cli (fresh process)
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
if python -c "import json,sys; m=json.load(open('mlx_workdir/train_metrics.json')); sys.exit(0 if m.get('gguf_supported') else 1)"; then
python tests/studio/run_real_mlx_smoke.py reload \

View file

@ -83,7 +83,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -100,7 +101,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail

View file

@ -226,6 +226,7 @@ jobs:
tests/sh/test_studio_home_node_dir.sh \
tests/sh/test_system_node_readonly.sh \
tests/sh/test_nvcc_meets_llama_minimum.sh \
tests/sh/test_resolve_cuda_archs.sh \
tests/sh/test_tauri_install_exit_order.sh \
tests/sh/test_torch_constraint.sh \
tests/sh/test_torch_flavor.sh; do

View file

@ -97,7 +97,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -114,7 +115,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
@ -364,7 +366,8 @@ jobs:
id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p gguf-cache
@ -380,7 +383,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
@ -845,7 +849,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -863,7 +868,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail

View file

@ -68,7 +68,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -85,7 +86,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail

View file

@ -91,7 +91,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -110,7 +111,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
@ -346,7 +348,8 @@ jobs:
id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p gguf-cache
@ -363,7 +366,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
@ -725,7 +729,8 @@ jobs:
# Authenticated + parallel: shared macos-14 NAT egress stalls
# multi-GB anonymous downloads.
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p gguf-cache
@ -752,7 +757,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail

View file

@ -63,7 +63,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail

View file

@ -68,7 +68,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -85,7 +86,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail

View file

@ -62,7 +62,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
@ -74,7 +75,8 @@ jobs:
- name: First update should be a no-op (prebuilt already validated)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update.log
@ -93,7 +95,8 @@ jobs:
- name: Second update must also be a no-op
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update2.log

View file

@ -82,7 +82,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -99,7 +100,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail

View file

@ -71,7 +71,8 @@ jobs:
# prebuilt path falls back to source build.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
@ -86,7 +87,8 @@ jobs:
# idempotency regressed.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update.log
@ -109,7 +111,8 @@ jobs:
# the first one.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update2.log

View file

@ -75,7 +75,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -124,7 +125,8 @@ jobs:
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null
# *>&1 captures Write-Host (Information stream) output;

View file

@ -127,7 +127,8 @@ jobs:
# described above (outcome != success).
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -179,7 +180,8 @@ jobs:
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null
# *>&1 captures Write-Host (Information stream) output;
@ -476,7 +478,8 @@ jobs:
id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p gguf-cache
@ -524,7 +527,8 @@ jobs:
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null
# *>&1 captures Write-Host (Information stream) output;
@ -906,7 +910,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -956,7 +961,8 @@ jobs:
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null
# *>&1 captures Write-Host (Information stream) output;
@ -1299,7 +1305,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -1376,7 +1383,8 @@ jobs:
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null
$ProgressPreference = 'SilentlyContinue'

View file

@ -91,7 +91,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -155,7 +156,8 @@ jobs:
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null
# *>&1 redirects ALL PowerShell streams (stdout, stderr,

View file

@ -133,7 +133,8 @@ jobs:
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null
# *>&1 captures Write-Host (Information stream) output;
@ -180,7 +181,8 @@ jobs:
- name: First update should be a no-op (prebuilt already validated)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update.log
@ -199,7 +201,8 @@ jobs:
- name: Second update must also be a no-op
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update2.log

View file

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.17
rev: v0.15.18
hooks:
- id: ruff
args:

View file

@ -86,7 +86,7 @@ unsloth studio -p 8888
```
For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally.
For a secure HTTPS link instead of a raw network port, use `unsloth studio --secure`. Studio stays bound to localhost and is served only through a free Cloudflare HTTPS tunnel (it fails closed if the tunnel can't start, so the raw port is never exposed).
To reach Studio over HTTPS, use `unsloth studio --secure`. Studio stays bound to localhost and is reached only through a free Cloudflare tunnel, which publishes it at a public `https://*.trycloudflare.com` URL (it fails closed if the tunnel can't start, so the raw port is never exposed). This makes Studio reachable from the internet, so anyone with the link and API key can use it and run code: keep your API key private (see Remote access below).
#### Docker
Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Run:

View file

@ -2595,6 +2595,7 @@ exit 0
step "launch" "to start later, run:"
substep "unsloth studio -p 8888"
substep "(add -H 0.0.0.0 to allow network / cloud access)"
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
Write-Host ""
}
} else {
@ -2615,6 +2616,7 @@ exit 0
substep "unsloth studio -p 8888"
}
substep "(add -H 0.0.0.0 to allow network / cloud access)"
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
Write-Host ""
}
}

View file

@ -1534,17 +1534,15 @@ _maybe_reroute_strixhalo_to_2404() {
_maybe_reroute_strixhalo_to_2404 || true
# ── Check system dependencies ──
# cmake and git are needed by unsloth studio setup to build the GGUF inference
# engine (llama.cpp). build-essential and libcurl-dev are also needed on Linux.
# cmake/git are only needed to *build* llama.cpp from source. Studio downloads a
# prebuilt by default, and setup.sh self-skips the source build when they're
# absent -- so macOS doesn't block on cmake (requiring it would force a manual
# Homebrew install). Linux keeps requiring them; its package manager has them.
tauri_log "STEP" "Checking system dependencies"
MISSING=""
command -v cmake >/dev/null 2>&1 || MISSING="$MISSING cmake"
command -v git >/dev/null 2>&1 || MISSING="$MISSING git"
case "$OS" in
macos)
# Xcode Command Line Tools provide the C/C++ compiler
# Xcode Command Line Tools provide the C/C++ compiler and git.
if ! xcode-select -p >/dev/null 2>&1; then
echo ""
echo "==> Xcode Command Line Tools are required."
@ -1553,8 +1551,19 @@ case "$OS" in
echo " After the installation completes, please re-run this script."
exit 1
fi
# cmake is only needed for a source build; the default prebuilt path
# doesn't use it, so its absence is not fatal -- no Homebrew prerequisite.
if command -v cmake >/dev/null 2>&1; then
step "deps" "all system dependencies found"
else
step "deps" "using prebuilt llama.cpp (cmake not found)" "$C_WARN"
substep "Install cmake only if you want a source build: brew install cmake"
fi
;;
linux|wsl)
MISSING=""
command -v cmake >/dev/null 2>&1 || MISSING="$MISSING cmake"
command -v git >/dev/null 2>&1 || MISSING="$MISSING git"
# curl or wget is needed for downloads; check both
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
MISSING="$MISSING curl"
@ -1562,27 +1571,12 @@ case "$OS" in
command -v gcc >/dev/null 2>&1 || MISSING="$MISSING build-essential"
# libcurl dev headers for llama.cpp HTTPS support
command -v curl-config >/dev/null 2>&1 || MISSING="$MISSING libcurl4-openssl-dev"
;;
esac
MISSING=$(echo "$MISSING" | sed 's/^ *//')
if [ -n "$MISSING" ]; then
echo ""
step "deps" "missing: $MISSING" "$C_WARN"
substep "These are needed to build the GGUF inference engine."
case "$OS" in
macos)
if ! command -v brew >/dev/null 2>&1; then
echo ""
echo " Homebrew is required to install them."
echo " Install Homebrew from https://brew.sh then re-run this script."
exit 1
fi
brew install $MISSING </dev/null
;;
linux|wsl)
MISSING=$(echo "$MISSING" | sed 's/^ *//')
if [ -n "$MISSING" ]; then
echo ""
step "deps" "missing: $MISSING" "$C_WARN"
substep "These are needed to build the GGUF inference engine."
if command -v apt-get >/dev/null 2>&1; then
_smart_apt_install $MISSING
else
@ -1597,12 +1591,12 @@ if [ -n "$MISSING" ]; then
echo " openSUSE: sudo zypper install cmake git gcc gcc-c++ make libcurl-devel"
exit 1
fi
;;
esac
echo ""
else
step "deps" "all system dependencies found"
fi
echo ""
else
step "deps" "all system dependencies found"
fi
;;
esac
# ── Install uv ──
tauri_log "STEP" "Installing uv package manager"
@ -3160,6 +3154,7 @@ if [ -t 1 ]; then
step "launch" "to start later, run:"
substep "unsloth studio -p 8888"
substep "(add -H 0.0.0.0 to allow network / cloud access)"
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
echo ""
;;
esac
@ -3181,5 +3176,6 @@ else
substep "unsloth studio -p 8888"
fi
substep "(add -H 0.0.0.0 to allow network / cloud access)"
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
echo ""
fi

View file

@ -46,6 +46,7 @@ studio = [
"*.sh",
"*.ps1",
"*.bat",
"node_prebuilt_pins.json",
"frontend/dist/**/*",
"frontend/*.json",
"frontend/*.ts",
@ -56,6 +57,7 @@ studio = [
"backend/requirements/**/*",
"backend/plugins/**/*",
"backend/assets/**/*.jinja",
"backend/assets/**/*.html",
"backend/core/data_recipe/oxc-validator/*.json",
"backend/core/data_recipe/oxc-validator/*.mjs",
]

View file

@ -72,13 +72,6 @@
"severity": "CRITICAL",
"evidence": "Archive: L317: a['TarFileType'] = tarfile.open(fileobj=_fileW,mode='w')\nNetwork: L330: x['SocketType'] = _socket = socket.socket()"
},
{
"package": "evaluate",
"file": "evaluate/utils/file_utils.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
"evidence": "L261: while True:"
},
{
"package": "execnet",
"file": "execnet/gateway_base.py",
@ -401,27 +394,6 @@
"severity": "CRITICAL",
"evidence": "Base64: L488: decoded_bytes = base64.b64decode(base64_encoded)\nSubprocess: L80: return subprocess.call(['which', name], | L100: p = subprocess.Popen(['pbcopy', 'w'], | L105: p = subprocess.Popen(['pbpaste', 'r'],"
},
{
"package": "pytest",
"file": "_pytest/_py/path.py",
"check": "Downloads and executes remote code",
"severity": "CRITICAL",
"evidence": "L1153: exec(f.read(), mod.__dict__)"
},
{
"package": "pytest",
"file": "_pytest/capture.py",
"check": "Reverse shell / bind shell pattern",
"severity": "CRITICAL",
"evidence": "L483: os.dup2(self.targetfd_invalid, targetfd) | L522: os.dup2(self.tmpfile.fileno(), self.targetfd) | L532: os.dup2(self.targetfd_save, self.targetfd)"
},
{
"package": "pytest",
"file": "_pytest/config/__init__.py",
"check": "Reverse shell / bind shell pattern",
"severity": "CRITICAL",
"evidence": "L260: os.dup2(devnull, sys.stdout.fileno())"
},
{
"package": "python-dateutil",
"file": "dateutil/__init__.py",
@ -527,6 +499,34 @@
"severity": "CRITICAL",
"evidence": "L13: __import__(__package__ + '.linalg') | L14: __import__(__package__ + '.fft')"
},
{
"package": "scipy",
"file": "scipy/_external/array_api_compat/cupy/__init__.py",
"check": "Downloads and executes remote code",
"severity": "CRITICAL",
"evidence": "L12: __import__(__package__ + '.linalg') | L13: __import__(__package__ + '.fft')"
},
{
"package": "scipy",
"file": "scipy/_external/array_api_compat/dask/array/__init__.py",
"check": "Downloads and executes remote code",
"severity": "CRITICAL",
"evidence": "L16: __import__(__package__ + '.linalg') | L17: __import__(__package__ + '.fft')"
},
{
"package": "scipy",
"file": "scipy/_external/array_api_compat/numpy/__init__.py",
"check": "Downloads and executes remote code",
"severity": "CRITICAL",
"evidence": "L23: __import__(__package__ + \".linalg\") | L25: __import__(__package__ + \".fft\")"
},
{
"package": "scipy",
"file": "scipy/_external/array_api_compat/torch/__init__.py",
"check": "Downloads and executes remote code",
"severity": "CRITICAL",
"evidence": "L13: __import__(__package__ + '.linalg') | L14: __import__(__package__ + '.fft')"
},
{
"package": "sentencepiece",
"file": "sentencepiece/__init__.py",
@ -814,6 +814,13 @@
"severity": "CRITICAL",
"evidence": "L67: input_gguf=\"/tmp/in.gguf\","
},
{
"package": "unsloth-zoo",
"file": "tests/test_mlx_save_export_regressions.py",
"check": "Writes to /tmp and executes (staged dropper)",
"severity": "CRITICAL",
"evidence": "L164: temporary_location=\"/tmp/ignored\","
},
{
"package": "unsloth-zoo",
"file": "tests/test_upstream_pinned_symbols_transformers.py",
@ -933,13 +940,6 @@
"severity": "HIGH",
"evidence": "Key: L187: \"-----BEGIN PUBLIC KEY-----\", | L188: \"-----BEGIN RSA PUBLIC KEY-----\",\nNetwork: L225: http_client: httpx.AsyncClient | None = None, | L411: else httpx.AsyncClient(timeout=httpx.Timeout(10.0))"
},
{
"package": "hypothesis",
"file": "hypothesis/internal/scrutineer.py",
"check": "Anti-analysis/sandbox evasion + suspicious behavior",
"severity": "HIGH",
"evidence": "Anti: L76: return sys.gettrace() is None | L113: sys.settrace(self.trace) | L136: sys.settrace(None)"
},
{
"package": "ipython",
"file": "IPython/core/debugger.py",
@ -982,20 +982,6 @@
"severity": "HIGH",
"evidence": "Obfusc: L709: return compile(source, filename, \"exec\")\nExec: L1228: exec(code, namespace)"
},
{
"package": "kgb",
"file": "kgb/spies.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
"evidence": "Obfusc: L934: eval(compile(func_code_str, '<string>', 'exec'),\nExec: L934: eval(compile(func_code_str, '<string>', 'exec'),"
},
{
"package": "langid",
"file": "langid/train/common.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
"evidence": "Obfusc: L44: yield marshal.load(t)\nExec: L85: key = eval(row[0])"
},
{
"package": "matplotlib",
"file": "matplotlib/sphinxext/plot_directive.py",
@ -1108,20 +1094,6 @@
"severity": "HIGH",
"evidence": "Obfusc: L45: mod = __import__(module_name, None, None, ['__all__'])\nExec: L154: exec(f.read(), custom_namespace)"
},
{
"package": "pytest",
"file": "_pytest/_py/path.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
"evidence": "Obfusc: L626: mod = __import__(hashtype) | L1118: __import__(modname)\nExec: L1153: exec(f.read(), mod.__dict__)"
},
{
"package": "pytest",
"file": "_pytest/assertion/rewrite.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
"evidence": "Obfusc: L393: co = marshal.load(fp) | L395: trace(f\"_read_pyc({source}): marshal.load error {e}\")\nExec: L188: exec(co, module.__dict__)"
},
{
"package": "scikit-learn",
"file": "sklearn/externals/array_api_compat/torch/__init__.py",
@ -1318,6 +1290,13 @@
"severity": "HIGH",
"evidence": "Obfusc: L3078: module = __import__('transformers', fromlist=[model_class_name])\nExec: L2960: exec(f\"from transformers.modeling_utils import ({', '.join(functions)})\", locals(), globals()) | L3006: exec(save_pretrained, globals(), functions)"
},
{
"package": "unsloth-zoo",
"file": "tests/test_mlx_trainer_internals.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
"evidence": "Obfusc: L430: assert ppl == pytest.approx(__import__(\"math\").exp(2.5))\nExec: L408: def eval(self):"
},
{
"package": "werkzeug",
"file": "werkzeug/routing/rules.py",

View file

@ -581,9 +581,16 @@ def compare(before_src: str, after_src: str, path: str) -> list[tuple[str, str]]
)
# 3. TARGET-CHANGED (same scope+name resolves to a different import target)
# Only a *swap* is dangerous: a BEFORE target that is no longer reachable in
# AFTER means a reference was silently re-pointed. A pure superset growth
# (tbefore <= tafter) is the benign `import pkg.subA` + `import pkg.subB`
# case: both statements bind the same top-level name `pkg` to the same
# package object and only *add* submodule attributes (e.g. adding
# `import urllib.error` next to `import urllib.request`). Nothing the name
# resolved to before is lost, so no reference is re-pointed -- skip it.
for key, tafter in b["target_by_use"].items():
tbefore = a["target_by_use"].get(key)
if tbefore and tbefore != tafter:
if tbefore and tbefore != tafter and (tbefore - tafter):
findings.append(
(
"BLOCKER",

View file

@ -0,0 +1,398 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>__TITLE__ - Unsloth</title>
<style>
@font-face {
font-family: "Hellix";
src: url("/p/_assets/fonts/Hellix-Medium.woff") format("woff");
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: "Hellix";
src: url("/p/_assets/fonts/Hellix-SemiBold.woff2") format("woff2");
font-weight: 600;
font-display: swap;
}
:root {
color-scheme: light dark;
--bg: #fefefd;
--fg: #0d0d0d;
--muted: #858279;
--border: #ececec;
--user-bubble: #f5f5f5;
--primary: #17b88b;
--composer-bg: #ffffff;
--composer-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.16);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1b1e;
--fg: #ececee;
--muted: #96979b;
--border: #3a3d42;
--user-bubble: #2d2e32;
--composer-bg: #2d2e32;
--composer-shadow: none;
}
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
display: flex;
flex-direction: column;
font:
15.5px/1.6 "Inter",
"Inter Variable",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
system-ui,
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.heading {
font-family: "Hellix", "Space Grotesk", system-ui, sans-serif;
}
header {
display: flex;
align-items: center;
gap: 9px;
padding: 14px 20px;
}
header img {
width: 22px;
height: 22px;
border-radius: 50%;
}
.brand {
font-family: "Hellix", "Space Grotesk", system-ui, sans-serif;
font-weight: 600;
font-size: 15px;
}
.model {
margin-left: auto;
max-width: 55%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12.5px;
color: var(--muted);
}
#log {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 8px 16px 24px;
}
#thread {
width: 100%;
max-width: 46.5rem;
margin: 0 auto;
display: flex;
flex-direction: column;
}
.welcome {
margin: auto;
text-align: center;
padding: 0 16px;
animation: fade 0.25s ease-out;
}
.welcome h1 {
margin: 0;
font-weight: 500;
font-size: 30px;
letter-spacing: -0.02em;
}
.welcome p {
margin: 0.55rem 0 0;
color: var(--muted);
font-size: 14px;
}
.msg {
font-size: 15.5px;
font-weight: 450;
letter-spacing: 0.01em;
word-wrap: break-word;
white-space: pre-wrap;
animation: fade 0.15s ease-out;
}
.user {
align-self: flex-end;
max-width: 80%;
margin-top: 24px;
padding: 10px 16px;
border-radius: 24px;
background: var(--user-bubble);
}
.assistant {
align-self: stretch;
margin-top: 16px;
line-height: 1.75;
}
.dots {
display: inline-flex;
gap: 5px;
align-items: center;
height: 1.6em;
}
.dots i {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--muted);
animation: blink 1.2s infinite;
}
.dots i:nth-child(2) {
animation-delay: 0.18s;
}
.dots i:nth-child(3) {
animation-delay: 0.36s;
}
.composer-wrap {
padding: 6px 16px 16px;
}
form {
width: 100%;
max-width: 46.5rem;
margin: 0 auto;
}
.composer {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 8px 8px 8px 18px;
border-radius: 28px;
background: var(--composer-bg);
box-shadow: var(--composer-shadow);
}
textarea {
flex: 1;
border: 0;
outline: 0;
resize: none;
background: transparent;
color: var(--fg);
font: inherit;
line-height: 1.5;
max-height: 200px;
padding: 8px 0;
}
textarea::placeholder {
color: var(--muted);
}
.send {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 0;
border-radius: 50%;
background: var(--primary);
color: #fff;
cursor: pointer;
}
.send:disabled {
opacity: 0.4;
cursor: default;
}
.foot {
margin: 9px auto 0;
max-width: 46.5rem;
text-align: center;
font-size: 11px;
color: var(--muted);
}
@keyframes blink {
0%,
80%,
100% {
opacity: 0.25;
}
40% {
opacity: 1;
}
}
@keyframes fade {
from {
opacity: 0;
transform: translateY(2px);
}
to {
opacity: 1;
transform: none;
}
}
</style>
</head>
<body>
<header>
<img src="/p/_assets/circle-logo-small.png" alt="" /><span class="brand"
>Unsloth</span
><span class="model">__TITLE__</span>
</header>
<main id="log">
<div id="welcome" class="welcome">
<h1 class="heading">Chat with your model</h1>
<p>Fine-tuned with Unsloth</p>
</div>
<div id="thread"></div>
</main>
<div class="composer-wrap">
<form id="f">
<div class="composer">
<textarea
id="i"
rows="1"
autocomplete="off"
placeholder="Message this model..."
></textarea>
<button id="b" class="send" aria-label="Send">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 19V5" />
<path d="M5 12l7-7 7 7" />
</svg>
</button>
</div>
<div class="foot">Served by Unsloth Studio</div>
</form>
</div>
<script>
const base = location.pathname.replace(/\/+$/, "");
const log = document.getElementById("log"),
thread = document.getElementById("thread"),
welcome = document.getElementById("welcome");
const form = document.getElementById("f"),
input = document.getElementById("i"),
btn = document.getElementById("b");
const msgs = [];
const down = () => {
log.scrollTop = log.scrollHeight;
};
function autosize() {
input.style.height = "auto";
input.style.height = Math.min(input.scrollHeight, 200) + "px";
}
input.addEventListener("input", autosize);
input.addEventListener("keydown", (e) => {
if (e.isComposing || e.keyCode === 229) return;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
// send() (not form.requestSubmit, unsupported on Safari < 16) guards the btn.
send();
}
});
function add(role) {
const d = document.createElement("div");
d.className = "msg " + role;
thread.appendChild(d);
down();
return d;
}
async function send() {
// One path for button + Enter; ignore while a request is in flight.
if (btn.disabled) return;
const content = input.value.trim();
if (!content) return;
if (welcome) welcome.style.display = "none";
input.value = "";
autosize();
btn.disabled = true;
msgs.push({ role: "user", content });
add("user").textContent = content;
const out = add("assistant");
out.innerHTML = '<span class="dots"><i></i><i></i><i></i></span>';
let acc = "";
try {
const r = await fetch(base + "/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "preview",
messages: msgs,
stream: true,
}),
});
if (!r.ok) {
out.textContent =
"Error " + r.status + ": " + (await r.text()).slice(0, 300);
msgs.pop();
input.value = content; // restore the prompt so the user can retry
autosize();
btn.disabled = false;
return;
}
const reader = r.body.getReader(),
dec = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (data === "[DONE]") continue;
try {
const j = JSON.parse(data);
const d =
j.choices &&
j.choices[0] &&
j.choices[0].delta &&
j.choices[0].delta.content;
if (d) {
acc += d;
out.textContent = acc;
down();
}
} catch (_) {}
}
}
if (!acc) out.textContent = "";
msgs.push({ role: "assistant", content: acc });
} catch (err) {
// Keep any streamed text, flag the break, restore the prompt for retry.
out.textContent = acc ? acc + "\n\n[connection lost]" : "Network error, please retry.";
msgs.pop();
input.value = content;
autosize();
}
btn.disabled = false;
input.focus();
}
form.addEventListener("submit", (e) => {
e.preventDefault();
send();
});
autosize();
input.focus();
</script>
</body>
</html>

View file

@ -26,6 +26,7 @@ from utils.hardware import (
)
from core.inference.audio_codecs import AudioCodecManager
from core.inference.runtime_context import runtime_context_length
from core.inference.message_content import content_to_text
from io import StringIO
import structlog
from loggers import get_logger
@ -1018,7 +1019,7 @@ class InferenceBackend:
user_message = ""
if messages and messages[-1]["role"] == "user":
import re
user_message = messages[-1]["content"]
user_message = content_to_text(messages[-1]["content"])
user_message = re.sub(r"<img[^>]*>", "", user_message).strip()
if not user_message:
@ -1181,7 +1182,7 @@ class InferenceBackend:
if messages:
for msg in reversed(messages):
if msg["role"] == "user" and msg.get("content"):
user_text = msg["content"]
user_text = content_to_text(msg["content"])
break
# ASR-specific default system prompt if none set
@ -1713,7 +1714,7 @@ class InferenceBackend:
for msg in messages:
role = msg.get("role", "")
content = msg.get("content", "")
content = content_to_text(msg.get("content", ""))
if role in ["system", "user", "assistant"] and content.strip():
if role == last_role:
@ -1801,7 +1802,7 @@ class InferenceBackend:
for msg in messages:
role = msg["role"]
content = msg["content"]
content = content_to_text(msg["content"])
formatted += f"<|start_header_id|>{role}<|end_header_id|>\n\n{content}<|eot_id|>"
formatted += "<|start_header_id|>assistant<|end_header_id|>\n\n"
@ -1817,14 +1818,14 @@ class InferenceBackend:
for msg in messages:
if msg["role"] == "system":
system_msg = msg["content"]
system_msg = content_to_text(msg["content"])
else:
conversation.append(msg)
i = 0
while i < len(conversation):
if conversation[i]["role"] == "user":
user_content = conversation[i]["content"]
user_content = content_to_text(conversation[i]["content"])
if system_msg and i == 0:
user_content = f"{system_msg}\n\n{user_content}"
@ -1832,7 +1833,7 @@ class InferenceBackend:
formatted += f"[INST] {user_content} [/INST]"
if i + 1 < len(conversation) and conversation[i + 1]["role"] == "assistant":
formatted += f" {conversation[i + 1]['content']}</s>"
formatted += f" {content_to_text(conversation[i + 1]['content'])}</s>"
i += 2
else:
formatted += " "
@ -1848,7 +1849,7 @@ class InferenceBackend:
for msg in messages:
role = msg["role"]
content = msg["content"]
content = content_to_text(msg["content"])
formatted += f"<|im_start|>{role}\n{content}<|im_end|>\n"
formatted += "<|im_start|>assistant\n"
@ -1860,16 +1861,17 @@ class InferenceBackend:
system_msg = None
for msg in messages:
content = content_to_text(msg["content"])
if msg["role"] == "system":
system_msg = msg["content"]
system_msg = content
elif msg["role"] == "user":
if system_msg:
formatted += f"### Instruction:\n{system_msg}\n\n### Input:\n{msg['content']}\n\n### Response:\n"
formatted += f"### Instruction:\n{system_msg}\n\n### Input:\n{content}\n\n### Response:\n"
system_msg = None
else:
formatted += f"### Human:\n{msg['content']}\n\n### Assistant:\n"
formatted += f"### Human:\n{content}\n\n### Assistant:\n"
elif msg["role"] == "assistant":
formatted += f"{msg['content']}\n\n"
formatted += f"{content}\n\n"
return formatted
@ -1879,7 +1881,7 @@ class InferenceBackend:
for msg in messages:
role = msg["role"].title()
content = msg["content"]
content = content_to_text(msg["content"])
formatted += f"{role}: {content}\n"
formatted += "Assistant: "

View file

@ -803,6 +803,10 @@ _MTP_MIN_SIZE_B = 3.0
# of free (see _fit_context_to_vram), plus a byte-accurate MTP draft reserve.
_CTX_FIT_VRAM_FRACTION = 0.95
# Apple unified memory is shared with the OS, so tighter than VRAM. Matches the
# 0.85 MLX uses in mlx_inference.py (_configure_memory_limits); not kept in sync.
_APPLE_UNIFIED_MEMORY_FRACTION = 0.85
# Flat MTP reserve, used only when GGUF dims are too sparse for the byte-accurate
# reserve (_estimate_mtp_overhead_bytes). Applied to both the fit budget and pin.
_MTP_VRAM_RESERVE_FRAC = 0.05
@ -2165,6 +2169,34 @@ class LlamaCppBackend:
``_get_gpu_memory`` for callers that only need free VRAM."""
return [(idx, free) for idx, free, _total in LlamaCppBackend._get_gpu_memory()]
@staticmethod
def _apple_metal_memory_budget_bytes() -> int:
"""Unified-memory budget for GGUF context fitting on Apple Silicon.
No GPU is enumerated on Metal, so the context would default to native and
over-commit unified memory ("Compute error." at decode, #5118/#6529). Use a
fraction of MLX's Metal working-set, else total RAM; 0 off Apple Silicon or
when unresolvable, so callers skip the cap.
"""
from utils.hardware import is_apple_silicon
if not is_apple_silicon():
return 0
rec_bytes = 0
try:
import mlx.core as mx
if mx.metal.is_available():
rec_bytes = int(mx.device_info().get("max_recommended_working_set_size") or 0)
except Exception:
rec_bytes = 0
if rec_bytes <= 0:
try:
import psutil
rec_bytes = int(psutil.virtual_memory().total)
except Exception:
return 0
return int(rec_bytes * _APPLE_UNIFIED_MEMORY_FRACTION)
@staticmethod
def _get_gpu_memory() -> list[tuple[int, int, int]]:
"""Query free AND total memory per GPU.
@ -5027,6 +5059,8 @@ class LlamaCppBackend:
else 0.0
)
_pin_fraction = self._GPU_PIN_VRAM_FRACTION - _flat_mtp_reserve
# Unified-memory budget (0 off Apple Silicon) for the no-GPU Metal cap below.
_apple_budget_mib = self._apple_metal_memory_budget_bytes() // (1024 * 1024)
def _restore_after_tensor_downgrade():
# Tensor mode dropped a quantized KV and stripped the cache
@ -5310,6 +5344,52 @@ class LlamaCppBackend:
# so the slider isn't on an unusable native ctx.
effective_ctx = min(4096, effective_ctx) if effective_ctx > 0 else 4096
elif _apple_budget_mib > 0 and effective_ctx > 0:
# No GPU on Metal: the branches above are skipped and the context
# stays at native, over-committing unified memory (#5118, #6529).
# Cap with the same fit math (--fit on stays as a backstop); only
# auto context shrinks, explicit is honored.
native_ctx_for_cap = self._context_length or effective_ctx
# Reserve the flat MTP fraction up front like the discrete
# _pin_fraction, so an unsized MTP draft (e.g. Qwen3.6-MTP, #6529)
# can't over-commit. No-op when MTP is off; exclusive with the
# byte-accurate _mtp_bytes reserve.
_apple_fit_budget_mib = int(
_apple_budget_mib * max(0.0, 1.0 - _flat_mtp_reserve)
)
if self._can_estimate_kv():
cap = self._fit_context_to_vram(
native_ctx_for_cap,
_apple_fit_budget_mib,
model_size_fit,
cache_type_kv,
n_parallel = n_parallel,
mtp_engaged = _mtp_reserves_gpu,
mtp_overhead_fn = mtp_overhead_fn,
budget_frac = 1.0,
total_mib = None,
)
_cap_footprint_mib = (
model_size_fit
+ self._estimate_kv_cache_bytes(
cap, cache_type_kv, n_parallel = n_parallel
)
+ _mtp_bytes(cap)
) / (1024 * 1024)
# Fit returns the request unchanged when it fits OR weights
# exceed budget; only the latter over-commits, so floor to 4096.
max_available_ctx = (
cap
if _cap_footprint_mib <= _apple_fit_budget_mib
else min(4096, native_ctx_for_cap)
)
else:
# No KV estimate: mirror the discrete file-size-only fallback
# and floor to 4096 rather than launch at native and over-commit.
max_available_ctx = min(4096, native_ctx_for_cap)
if not explicit_ctx:
effective_ctx = max_available_ctx
# MTP reserve at the final context, for the logs below.
_mtp_reserve_bytes = _mtp_bytes(effective_ctx) if _mtp_will_engage else 0
if _mtp_will_engage:
@ -7757,6 +7837,15 @@ class LlamaCppBackend:
_accumulated_completion_tokens = 0
_accumulated_predicted_ms = 0.0
_accumulated_predicted_n = 0
# GGUF buffers reasoning; emit server-side timing before answer text.
_reasoning_started_at: Optional[float] = None
_reasoning_summary_emitted = False
def _reasoning_summary_event(started_at: float) -> dict:
return {
"type": "reasoning_summary",
"duration_ms": round((time.monotonic() - started_at) * 1000.0),
}
def _strip_tool_markup(
text: str,
@ -7792,13 +7881,18 @@ class LlamaCppBackend:
_mt["predicted_per_second"] = _mt["predicted_n"] / (
_mt["predicted_ms"] / 1000.0
)
_usage = {
"prompt_tokens": _fp,
"completion_tokens": _tc,
"total_tokens": _fp + _tc,
}
# Preserve KV-cache hit details (cached_tokens) so the tool path
# reports them like the standard non-tool path does, not always 0.
if _fu.get("prompt_tokens_details"):
_usage["prompt_tokens_details"] = _fu["prompt_tokens_details"]
return {
"type": "metadata",
"usage": {
"prompt_tokens": _fp,
"completion_tokens": _tc,
"total_tokens": _fp + _tc,
},
"usage": _usage,
"timings": _mt,
"finish_reason": finish_reason,
}
@ -7894,6 +7988,9 @@ class LlamaCppBackend:
content_buffer = "" # Raw content held during BUFFERING
content_accum = "" # All content tokens (for tool parsing)
reasoning_accum = ""
# Time each reasoning pass so final answers can replace tool timing.
_reasoning_started_at = None
_reasoning_summary_emitted = False
cumulative_display = "" # Cumulative yielded text (with <think>)
in_thinking = False
has_content_tokens = False
@ -8068,6 +8165,8 @@ class LlamaCppBackend:
# between tool iterations).
reasoning = delta.get("reasoning_content", "")
if reasoning:
if _reasoning_started_at is None:
_reasoning_started_at = time.monotonic()
reasoning_accum += reasoning
if detect_state == _S_STREAMING:
if not in_thinking:
@ -8083,6 +8182,13 @@ class LlamaCppBackend:
# ── Content tokens ──
token = delta.get("content", "")
if token:
# First answer token ends reasoning.
if (
_reasoning_started_at is not None
and not _reasoning_summary_emitted
):
_reasoning_summary_emitted = True
yield _reasoning_summary_event(_reasoning_started_at)
has_content_tokens = True
content_accum += token
@ -8180,9 +8286,10 @@ class LlamaCppBackend:
),
}
elif reasoning_accum and not has_content_tokens:
# Reasoning-only response: show reasoning as plain
# text, matching the final streaming pass for
# models that put everything in reasoning.
# Reasoning-only reply: show it as plain text.
if _reasoning_started_at is not None and not _reasoning_summary_emitted:
_reasoning_summary_emitted = True
yield _reasoning_summary_event(_reasoning_started_at)
cumulative_display = reasoning_accum
if not _suppress_visible_output:
yield {
@ -8591,6 +8698,8 @@ class LlamaCppBackend:
in_thinking = False
has_content_tokens = False
reasoning_text = ""
_final_reasoning_started_at: Optional[float] = None
_final_reasoning_summary_emitted = False
_metadata_usage = None
_metadata_timings = None
_metadata_finish_reason = None
@ -8616,6 +8725,12 @@ class LlamaCppBackend:
continue
if line == "data: [DONE]":
if in_thinking:
if (
_final_reasoning_started_at is not None
and not _final_reasoning_summary_emitted
):
_final_reasoning_summary_emitted = True
yield _reasoning_summary_event(_final_reasoning_started_at)
if has_content_tokens:
cumulative += "</think>"
yield {
@ -8648,6 +8763,8 @@ class LlamaCppBackend:
reasoning = delta.get("reasoning_content", "")
if reasoning:
if _final_reasoning_started_at is None:
_final_reasoning_started_at = time.monotonic()
reasoning_text += reasoning
if not in_thinking:
cumulative += "<think>"
@ -8657,6 +8774,12 @@ class LlamaCppBackend:
token = delta.get("content", "")
if token:
if (
_final_reasoning_started_at is not None
and not _final_reasoning_summary_emitted
):
_final_reasoning_summary_emitted = True
yield _reasoning_summary_event(_final_reasoning_started_at)
has_content_tokens = True
if in_thinking:
cumulative += "</think>"

View file

@ -0,0 +1,38 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Normalize chat-message `content` (string or OpenAI multimodal list) to text.
String-only formatting paths called string ops directly on `content` and broke
on the list form (#4383). `content_to_text` collapses either shape to a string,
dropping non-text parts. No heavy imports, so it is unit-testable alone.
"""
from __future__ import annotations
from typing import Any
def content_to_text(content: Any) -> str:
"""Plain text of a `content`: str unchanged, list/tuple text parts newline-joined
(non-text dropped), None to "", else str(content)."""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, (list, tuple)):
parts = []
for item in content:
if isinstance(item, str):
if item:
parts.append(item)
elif isinstance(item, dict):
# Skip non-text parts (image_url, input_audio, ...).
part_type = item.get("type")
if part_type is not None and part_type != "text":
continue
text = item.get("text")
if isinstance(text, str) and text:
parts.append(text)
return "\n".join(parts)
return str(content)

View file

@ -7,25 +7,31 @@ Tolerates missing closing tags in either ``<tool_call>{json}</tool_call>``
or ``<function=name><parameter=k>v...`` shape.
"""
import json
import re
from core import tool_healing as _tool_healing
# _TOOL_CLOSED_PATS: closed pairs only. _TOOL_ALL_PATS: also trailing unclosed
# runs so truncated tails don't leak markup. The [\w-] name set matches OpenAI's
# so hyphenated MCP tool names (mcp__srv__list-issues) parse like built-ins.
_TOOL_CLOSED_PATS = [
re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL),
re.compile(r"<function=[\w-]+>.*?</function>", re.DOTALL),
]
_TOOL_ALL_PATS = _TOOL_CLOSED_PATS + [
re.compile(r"<tool_call>.*$", re.DOTALL),
re.compile(r"<function=[\w-]+>.*$", re.DOTALL),
]
_TOOL_ALL_PATS = _tool_healing._TOOL_ALL_PATS
def parse_tool_calls_from_text(
content: str,
*,
id_offset: int = 0,
allow_incomplete: bool = True,
) -> list[dict]:
return _tool_healing.parse_tool_calls_from_text(
content,
id_offset = id_offset,
allow_incomplete = allow_incomplete,
)
def strip_tool_markup(text: str, *, final: bool = False) -> str:
return _tool_healing.strip_tool_call_markup(text, final = final)
# Prefixes the streaming buffer watches for to gate in-progress text.
TOOL_XML_SIGNALS = ("<tool_call>", "<function=")
TOOL_XML_SIGNALS = ("<tool_call>", "<|tool_call>", "<function=")
# Nudges + error prefixes shared by the GGUF and safetensors loops.
@ -74,199 +80,6 @@ RAG_SEARCH_CAP_NUDGE = (
)
# Pre-compiled patterns reused by ``parse_tool_calls_from_text``.
_TC_JSON_START_RE = re.compile(r"<tool_call>\s*\{")
_TC_FUNC_START_RE = re.compile(r"<function=([\w-]+)>\s*")
_TC_END_TAG_RE = re.compile(r"</tool_call>")
_TC_FUNC_CLOSE_RE = re.compile(r"\s*</function>\s*$")
# [\w-] so hyphenated MCP param names (issue-number) aren't dropped.
_TC_PARAM_START_RE = re.compile(r"<parameter=([\w-]+)>\s*")
_TC_PARAM_CLOSE_RE = re.compile(r"\s*</parameter>\s*$")
_PARAM_CLOSE_TAG = "</parameter>"
_FUNC_CLOSE_TAG = "</function>"
def _inside_open_parameter(content: str, pos: int) -> bool:
"""Return True when ``pos`` falls inside an unclosed parameter value."""
last_param_start = -1
for match in _TC_PARAM_START_RE.finditer(content, 0, pos):
last_param_start = match.start()
if last_param_start < 0:
return False
last_param_close = content.rfind(_PARAM_CLOSE_TAG, 0, pos)
last_func_close = content.rfind(_FUNC_CLOSE_TAG, 0, pos)
return last_param_start > max(last_param_close, last_func_close)
def strip_tool_markup(text: str, *, final: bool = False) -> str:
"""Strip tool-call XML from streamed text.
``final=False`` only removes closed pairs (used during streaming so
in-progress XML stays buffered). ``final=True`` also removes a
trailing unclosed run and trims the result.
"""
pats = _TOOL_ALL_PATS if final else _TOOL_CLOSED_PATS
for pat in pats:
text = pat.sub("", text)
return text.strip() if final else text
def parse_tool_calls_from_text(
content: str,
*,
id_offset: int = 0,
allow_incomplete: bool = True,
) -> list[dict]:
"""Parse OpenAI-format ``tool_calls`` from model text.
Returns a list of ``{"id", "type", "function": {"name", "arguments"}}``
dicts. ``arguments`` is always a JSON string so callers can hand it
straight back into an OpenAI-style response.
Handles two shapes:
- JSON inside ``<tool_call>`` tags:
``<tool_call>{"name":"web_search","arguments":{"query":"..."}}</tool_call>``
- XML-style function blocks:
``<function=name><parameter=k>v</parameter></function>``
``allow_incomplete=True`` keeps the historical healing behavior for
missing closing tags. ``allow_incomplete=False`` accepts only
well-formed wrappers so disabled Auto-Heal can still parse valid
local tool protocol without repairing truncated output.
"""
tool_calls: list[dict] = []
# Pattern 1: <tool_call>{json}. Balanced-brace scan, skipping braces in
# JSON strings.
for m in _TC_JSON_START_RE.finditer(content):
brace_start = m.end() - 1 # opening {
depth, i = 0, brace_start
in_string = False
while i < len(content):
ch = content[i]
if in_string:
if ch == "\\" and i + 1 < len(content):
i += 2
continue
if ch == '"':
in_string = False
elif ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
break
i += 1
if depth != 0:
continue
if not allow_incomplete:
tail_after_json = content[i + 1 :].lstrip()
if _TC_END_TAG_RE.match(tail_after_json) is None:
continue
json_str = content[brace_start : i + 1]
try:
obj = json.loads(json_str)
tc = {
"id": f"call_{id_offset + len(tool_calls)}",
"type": "function",
"function": {
"name": obj.get("name", ""),
"arguments": obj.get("arguments", {}),
},
}
if isinstance(tc["function"]["arguments"], dict):
tc["function"]["arguments"] = json.dumps(tc["function"]["arguments"])
tool_calls.append(tc)
except (json.JSONDecodeError, ValueError):
pass
# Pattern 2: <function=name><parameter=k>v... -- closing tags optional;
# </function> isn't a body boundary since code values can contain it.
if not tool_calls:
func_starts = [
fm
for fm in _TC_FUNC_START_RE.finditer(content)
if not _inside_open_parameter(content, fm.start())
]
for idx, fm in enumerate(func_starts):
func_name = fm.group(1)
body_start = fm.end()
next_func = func_starts[idx + 1].start() if idx + 1 < len(func_starts) else len(content)
end_tag = _TC_END_TAG_RE.search(content[body_start:])
if end_tag:
body_end = body_start + end_tag.start()
else:
body_end = len(content)
body_end = min(body_end, next_func)
body = content[body_start:body_end]
if not allow_incomplete:
# Bound the body at the closing </function> tag rather than
# the end of the response, so a complete call followed by
# trailing prose is still accepted (matching the JSON-style
# <tool_call> path, which already tolerates trailing text).
# rfind picks the last </function>, so a literal </function>
# inside a code parameter value stays in the body.
close_idx = body.rfind(_FUNC_CLOSE_TAG)
if close_idx < 0:
continue
body = body[:close_idx]
else:
body = _TC_FUNC_CLOSE_RE.sub("", body)
arguments: dict = {}
param_starts = list(_TC_PARAM_START_RE.finditer(body))
if len(param_starts) == 1:
# Single param: take everything to body end so an embedded
# </parameter> in code strings is preserved.
pm = param_starts[0]
val = body[pm.end() :]
if not allow_incomplete:
stripped_val = val.rstrip()
if not stripped_val.endswith(_PARAM_CLOSE_TAG):
continue
val = stripped_val[: -len(_PARAM_CLOSE_TAG)]
else:
val = _TC_PARAM_CLOSE_RE.sub("", val)
arguments[pm.group(1)] = val.strip()
else:
valid_params = True
for pidx, pm in enumerate(param_starts):
param_name = pm.group(1)
val_start = pm.end()
next_param = (
param_starts[pidx + 1].start()
if pidx + 1 < len(param_starts)
else len(body)
)
val = body[val_start:next_param]
if not allow_incomplete:
stripped_val = val.rstrip()
if not stripped_val.endswith(_PARAM_CLOSE_TAG):
valid_params = False
break
val = stripped_val[: -len(_PARAM_CLOSE_TAG)]
else:
val = _TC_PARAM_CLOSE_RE.sub("", val)
arguments[param_name] = val.strip()
if not valid_params:
continue
tc = {
"id": f"call_{id_offset + len(tool_calls)}",
"type": "function",
"function": {
"name": func_name,
"arguments": json.dumps(arguments),
},
}
tool_calls.append(tc)
return tool_calls
def has_tool_signal(text: str) -> bool:
"""Return True if ``text`` contains any tool-call XML signal."""
return any(s in text for s in TOOL_XML_SIGNALS)

View file

@ -46,6 +46,23 @@ def _device() -> str:
return _TORCH_DEVICE.get(get_device(), "cpu")
_torchao_stub_done = False
def _install_torchao_stub_once() -> None:
"""Neutralize torchao before importing sentence-transformers. On Windows ROCm,
torchao (pulled in by transformers.quantizers) imports an absent c10d backend
and aborts, dropping the embedder to llama-server. Workers stub it too; the
embedder runs in the main process. No-op elsewhere; runs once under ``_lock``."""
global _torchao_stub_done
if _torchao_stub_done:
return
_torchao_stub_done = True
from core._torchao_stub import install_torchao_windows_rocm_stub
install_torchao_windows_rocm_stub()
def _get(model_name: str | None = None):
"""Cached SentenceTransformer, (re)loading on a name change. Loaded in fp16
for a ~1.5x speedup at negligible accuracy loss."""
@ -53,6 +70,7 @@ def _get(model_name: str | None = None):
name = model_name or config.EMBEDDING_MODEL
with _lock:
if _model is None or _name != name:
_install_torchao_stub_once()
from sentence_transformers import SentenceTransformer
device = _device()

View file

@ -1,14 +1,10 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tool-call XML parsing and stripping helpers.
"""Lightweight tool-call XML parsing and stripping helpers.
Extracted verbatim from studio/backend/core/inference/llama_cpp.py so external
inference servers can reuse the logic without importing the inference
External inference servers import this module without pulling in the inference
orchestrator, structlog, httpx, or the rest of the studio backend.
Regexes and bodies are byte-for-byte identical to the original; any change must
preserve that. test_tool_healing_extraction_is_exact.py verifies via AST.
"""
import json
@ -19,86 +15,370 @@ import re
# issue-number) parse alongside the built-ins.
_TOOL_CLOSED_PATS = [
re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL),
re.compile(r"<\|tool_call>.*?<tool_call\|>", re.DOTALL),
re.compile(r"<tool_call\|>"),
re.compile(r"<function=[\w-]+>.*?</function>", re.DOTALL),
]
_TOOL_ALL_PATS = _TOOL_CLOSED_PATS + [
re.compile(r"<tool_call>.*$", re.DOTALL),
re.compile(r"<\|tool_call>.*$", re.DOTALL),
re.compile(r"<function=[\w-]+>.*$", re.DOTALL),
]
# Pre-compiled patterns for tool-call XML parsing.
_TC_JSON_START_RE = re.compile(r"<tool_call>\s*\{")
_TC_GEMMA_START_RE = re.compile(r"<\|tool_call>call:([\w-]+)\s*\{")
_TC_FUNC_START_RE = re.compile(r"<function=([\w-]+)>\s*")
_TC_END_TAG_RE = re.compile(r"</tool_call>")
_TC_GEMMA_END_TAG_RE = re.compile(r"<tool_call\|>")
_TC_FUNC_CLOSE_RE = re.compile(r"\s*</function>\s*$")
_TC_PARAM_START_RE = re.compile(r"<parameter=([\w-]+)>\s*")
_TC_PARAM_CLOSE_RE = re.compile(r"\s*</parameter>\s*$")
_GEMMA_QUOTE = '<|"|>'
_PARAM_CLOSE_TAG = "</parameter>"
_FUNC_CLOSE_TAG = "</function>"
# A bare (unquoted) Gemma value ends at `}` or at a comma that begins the next
# `key:` pair. A comma NOT followed by a key token is part of the value (e.g.
# `location:New York, NY`), so it must not terminate the value. The key token
# must be identifier-shaped (start with a letter or underscore); a comma
# followed by digits-then-colon is value text such as a timestamp or ratio
# (`meet at 10:00, 11:00 tomorrow`), not a new key.
_GEMMA_NEXT_KEY_RE = re.compile(r"\s*[A-Za-z_][\w-]*\s*:")
def parse_tool_calls_from_text(content: str) -> list[dict]:
"""
Parse tool calls from XML markup in content text.
def _balanced_brace_end(
content: str,
brace_start: int,
*,
gemma_quotes: bool = False,
) -> int:
depth = 0
i = brace_start
in_string = False
in_gemma_string = False
while i < len(content):
if gemma_quotes and not in_string and content.startswith(_GEMMA_QUOTE, i):
in_gemma_string = not in_gemma_string
i += len(_GEMMA_QUOTE)
continue
ch = content[i]
if in_gemma_string:
i += 1
continue
if in_string:
if ch == "\\" and i + 1 < len(content):
i += 2
continue
if ch == '"':
in_string = False
elif ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return i
i += 1
return -1
def _balanced_bracket_end(src: str, start: int) -> int:
"""Index of the ``]`` matching the ``[`` at ``start``, or -1. Tracks nested
``[]``/``{}`` and double-quoted strings."""
depth = 0
i = start
in_string = False
while i < len(src):
ch = src[i]
if in_string:
if ch == "\\" and i + 1 < len(src):
i += 2
continue
if ch == '"':
in_string = False
elif ch == '"':
in_string = True
elif ch in "[{":
depth += 1
elif ch in "]}":
depth -= 1
if depth == 0:
return i
i += 1
return -1
def _split_top_level_commas(src: str) -> list:
"""Split on commas that are not inside a nested ``[]``/``{}`` or a string."""
parts: list[str] = []
depth = 0
in_string = False
start = 0
i = 0
while i < len(src):
ch = src[i]
if in_string:
if ch == "\\" and i + 1 < len(src):
i += 2
continue
if ch == '"':
in_string = False
elif ch == '"':
in_string = True
elif ch in "[{":
depth += 1
elif ch in "]}":
depth -= 1
elif ch == "," and depth == 0:
parts.append(src[start:i])
start = i + 1
i += 1
parts.append(src[start:])
return parts
def _quote_gemma_array_elements(body: str) -> str:
"""Normalise the elements of a Gemma array value so json.loads succeeds.
Gemma may emit ``labels:[bug,ui]`` without per-element quotes, or arrays of
objects (``items:[{path:a}]``) whose keys/values also lack quotes; left
as-is json.loads fails and the whole call is dropped. Bare string elements
are quoted, object and nested-array elements are normalised recursively, and
quoted strings (already normalised from ``<|"|>``), numbers, and JSON
literals are preserved."""
out: list[str] = []
for element in _split_top_level_commas(body):
stripped = element.strip()
if not stripped or stripped[0] == '"':
out.append(element)
continue
if stripped[0] == "{":
# Object element: quote its keys/bare values like a top-level object.
out.append(_quote_gemma_object_keys(stripped))
continue
if stripped[0] == "[":
# Nested array: normalise its elements too.
inner_end = _balanced_bracket_end(stripped, 0)
if inner_end == len(stripped) - 1:
out.append("[" + _quote_gemma_array_elements(stripped[1:inner_end]) + "]")
else:
out.append(element)
continue
try:
json.loads(stripped)
out.append(element)
except (json.JSONDecodeError, ValueError):
out.append(json.dumps(stripped))
return ",".join(out)
def _normalise_gemma_quoted_strings(src: str) -> str:
parts: list[str] = []
i = 0
while i < len(src):
if not src.startswith(_GEMMA_QUOTE, i):
parts.append(src[i])
i += 1
continue
end = src.find(_GEMMA_QUOTE, i + len(_GEMMA_QUOTE))
if end < 0:
parts.append(src[i:])
break
raw_value = src[i + len(_GEMMA_QUOTE) : end]
parts.append(json.dumps(raw_value))
i = end + len(_GEMMA_QUOTE)
return "".join(parts)
def _quote_gemma_object_keys(src: str) -> str:
parts: list[str] = []
i = 0
in_string = False
while i < len(src):
ch = src[i]
if in_string:
parts.append(ch)
if ch == "\\" and i + 1 < len(src):
parts.append(src[i + 1])
i += 2
continue
if ch == '"':
in_string = False
i += 1
continue
if ch == '"':
in_string = True
parts.append(ch)
i += 1
continue
if ch not in "{,":
parts.append(ch)
i += 1
continue
parts.append(ch)
i += 1
key_start = i
while i < len(src) and src[i].isspace():
i += 1
key_name_start = i
while i < len(src) and (src[i].isalnum() or src[i] in "_-"):
i += 1
key_name = src[key_name_start:i]
colon_pos = i
while colon_pos < len(src) and src[colon_pos].isspace():
colon_pos += 1
if key_name and colon_pos < len(src) and src[colon_pos] == ":":
parts.append(src[key_start:key_name_start])
parts.append(json.dumps(key_name))
parts.append(src[i:colon_pos])
parts.append(":")
i = colon_pos + 1
# Gemma may emit bare string values ({unit:celsius}); quote them so
# json.loads succeeds. JSON scalars/objects/arrays/quoted stay as-is.
ws = i
while i < len(src) and src[i].isspace():
i += 1
parts.append(src[ws:i])
if i < len(src) and src[i] == "[":
# Array value: quote bare string elements (e.g. labels:[bug,ui])
# so json.loads succeeds instead of dropping the call.
arr_end = _balanced_bracket_end(src, i)
if arr_end < 0:
parts.append(src[i:])
i = len(src)
else:
parts.append("[" + _quote_gemma_array_elements(src[i + 1 : arr_end]) + "]")
i = arr_end + 1
elif i < len(src) and src[i] not in '"{':
v_start = i
# Consume the bare value up to `}` or a comma that starts the
# next key:value pair; a comma inside the value (e.g.
# `New York, NY`) does not terminate it.
while i < len(src):
if src[i] == "}":
break
if src[i] == "," and _GEMMA_NEXT_KEY_RE.match(src, i + 1):
break
i += 1
raw = src[v_start:i]
try:
json.loads(raw.strip())
parts.append(raw)
except (json.JSONDecodeError, ValueError):
parts.append(json.dumps(raw.strip()) if raw.strip() else raw)
else:
parts.append(src[key_start:i])
return "".join(parts)
def _gemma_arguments_to_json(args_src: str) -> dict:
"""Parse Gemma 4's native call:name{key:value} argument object."""
args_src = args_src.strip()
if not args_src:
return {}
src = _normalise_gemma_quoted_strings(args_src)
src = "{" + src + "}"
src = _quote_gemma_object_keys(src)
return json.loads(src)
def _inside_open_parameter(content: str, pos: int) -> bool:
"""Return True when ``pos`` falls inside an unclosed parameter value."""
last_param_start = -1
for match in _TC_PARAM_START_RE.finditer(content, 0, pos):
last_param_start = match.start()
if last_param_start < 0:
return False
last_param_close = content.rfind(_PARAM_CLOSE_TAG, 0, pos)
last_func_close = content.rfind(_FUNC_CLOSE_TAG, 0, pos)
return last_param_start > max(last_param_close, last_func_close)
def parse_tool_calls_from_text(
content: str,
*,
id_offset: int = 0,
allow_incomplete: bool = True,
) -> list[dict]:
"""Parse OpenAI-format tool calls from model text.
Handles formats like:
<tool_call>{"name":"web_search","arguments":{"query":"..."}}</tool_call>
<|tool_call>call:web_search{query:"..."}<tool_call|>
<tool_call><function=web_search><parameter=query>...</parameter></function></tool_call>
Closing tags (</tool_call>, </function>, </parameter>) are all
optional since models frequently omit them.
"""
tool_calls = []
# Pattern 1: JSON inside <tool_call> tags. Balanced-brace extraction that
# skips braces inside JSON strings.
tool_calls: list[dict] = []
# Collect JSON- and Gemma-format candidates with their byte spans, then
# accept them in document order. Both order and spans matter:
# * tools execute in returned order, so a call appearing earlier in the
# text must be emitted first even across the two formats;
# * a tool-call marker INSIDE another call's argument string is data, not a
# call, so a candidate starting within an already accepted span is
# skipped (covers a JSON marker nested in a Gemma arg and a Gemma marker
# nested in a JSON arg alike, regardless of which format is outer).
candidates = [] # (start, brace_end, kind, match)
for m in _TC_JSON_START_RE.finditer(content):
brace_start = m.end() - 1 # position of the opening {
depth, i = 0, brace_start
in_string = False
while i < len(content):
ch = content[i]
if in_string:
if ch == "\\" and i + 1 < len(content):
i += 2 # skip escaped character
continue
if ch == '"':
in_string = False
elif ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
break
i += 1
if depth == 0:
json_str = content[brace_start : i + 1]
try:
obj = json.loads(json_str)
tc = {
"id": f"call_{len(tool_calls)}",
"type": "function",
"function": {
"name": obj.get("name", ""),
"arguments": obj.get("arguments", {}),
},
}
if isinstance(tc["function"]["arguments"], dict):
tc["function"]["arguments"] = json.dumps(tc["function"]["arguments"])
tool_calls.append(tc)
except (json.JSONDecodeError, ValueError):
pass
# A marker that begins inside an open <function=...><parameter=...> value
# is that parameter's data, not its own call; skip it (same guard the
# XML-style parser below applies to nested <function= markers).
if _inside_open_parameter(content, m.start()):
continue
end = _balanced_brace_end(content, m.end() - 1)
if end >= 0:
candidates.append((m.start(), end, "json", m))
for m in _TC_GEMMA_START_RE.finditer(content):
if _inside_open_parameter(content, m.start()):
continue
end = _balanced_brace_end(content, m.end() - 1, gemma_quotes = True)
if end >= 0:
candidates.append((m.start(), end, "gemma", m))
candidates.sort(key = lambda c: c[0])
spans = [(s, e) for s, e, _kind, _m in candidates]
for idx, (start, end, kind, m) in enumerate(candidates):
# Skip a candidate nested inside another candidate's brace span: it is
# the enclosing call's argument data, not its own call. Checked against
# every candidate span (not only the ones that parsed successfully), so a
# marker inside an outer call that later fails to normalize is still
# never promoted to its own executable tool call.
if any(s <= start and end <= e for j, (s, e) in enumerate(spans) if j != idx):
continue
if not allow_incomplete:
tail = content[end + 1 :].lstrip()
close_re = _TC_END_TAG_RE if kind == "json" else _TC_GEMMA_END_TAG_RE
if close_re.match(tail) is None:
continue
try:
if kind == "json":
obj = json.loads(content[m.end() - 1 : end + 1])
name = obj.get("name", "")
arguments = obj.get("arguments", {})
if isinstance(arguments, dict):
arguments = json.dumps(arguments)
else:
name = m.group(1)
arguments = json.dumps(_gemma_arguments_to_json(content[m.end() : end]))
except (json.JSONDecodeError, ValueError):
continue
tool_calls.append(
{
"id": f"call_{id_offset + len(tool_calls)}",
"type": "function",
"function": {"name": name, "arguments": arguments},
}
)
# Pattern 2: XML-style <function=name><parameter=key>value</parameter></function>
# All closing tags optional; models frequently omit them.
if not tool_calls:
# Step 1: Find <function=name> positions and extract bodies. Use only
# </tool_call> or the next <function= as hard boundaries (</function>
# can appear in code values); trim a trailing </function> afterwards.
func_starts = list(_TC_FUNC_START_RE.finditer(content))
func_starts = [
fm
for fm in _TC_FUNC_START_RE.finditer(content)
if not _inside_open_parameter(content, fm.start())
]
for idx, fm in enumerate(func_starts):
func_name = fm.group(1)
body_start = fm.end()
# Boundaries: next <function= tag or </tool_call>
next_func = func_starts[idx + 1].start() if idx + 1 < len(func_starts) else len(content)
end_tag = _TC_END_TAG_RE.search(content[body_start:])
if end_tag:
@ -107,36 +387,52 @@ def parse_tool_calls_from_text(content: str) -> list[dict]:
body_end = len(content)
body_end = min(body_end, next_func)
body = content[body_start:body_end]
body = _TC_FUNC_CLOSE_RE.sub("", body) # trim closing </function>
if not allow_incomplete:
close_idx = body.rfind(_FUNC_CLOSE_TAG)
if close_idx < 0:
continue
body = body[:close_idx]
else:
body = _TC_FUNC_CLOSE_RE.sub("", body)
# Step 2: Extract parameters from body. For single-parameter
# functions, use body end as the only boundary to avoid matching
# </parameter> inside code strings.
arguments = {}
arguments: dict = {}
param_starts = list(_TC_PARAM_START_RE.finditer(body))
if len(param_starts) == 1:
# Value is everything after the tag to end of body, less a
# trailing </parameter>.
pm = param_starts[0]
val = body[pm.end() :]
val = _TC_PARAM_CLOSE_RE.sub("", val)
if not allow_incomplete:
stripped_val = val.rstrip()
if not stripped_val.endswith(_PARAM_CLOSE_TAG):
continue
val = stripped_val[: -len(_PARAM_CLOSE_TAG)]
else:
val = _TC_PARAM_CLOSE_RE.sub("", val)
arguments[pm.group(1)] = val.strip()
else:
valid_params = True
for pidx, pm in enumerate(param_starts):
param_name = pm.group(1)
val_start = pm.end()
# Value ends at next <parameter= or end of body
next_param = (
param_starts[pidx + 1].start()
if pidx + 1 < len(param_starts)
else len(body)
)
val = body[val_start:next_param]
val = _TC_PARAM_CLOSE_RE.sub("", val) # trim trailing </parameter>
if not allow_incomplete:
stripped_val = val.rstrip()
if not stripped_val.endswith(_PARAM_CLOSE_TAG):
valid_params = False
break
val = stripped_val[: -len(_PARAM_CLOSE_TAG)]
else:
val = _TC_PARAM_CLOSE_RE.sub("", val)
arguments[param_name] = val.strip()
if not valid_params:
continue
tc = {
"id": f"call_{len(tool_calls)}",
"id": f"call_{id_offset + len(tool_calls)}",
"type": "function",
"function": {
"name": func_name,

View file

@ -24,9 +24,10 @@ from datetime import datetime, timezone
from loggers import get_logger
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, Tuple, Any
from typing import Optional, Tuple, Any, TYPE_CHECKING
import matplotlib.pyplot as plt
if TYPE_CHECKING:
import matplotlib.pyplot as plt
from utils.hardware import prepare_gpu_selection
from utils.native_path_leases import (
native_path_secret_removed_for_child_start,
@ -36,6 +37,30 @@ from utils.paths import outputs_root
logger = get_logger(__name__)
_pyplot = None
_pyplot_failed = False
def _load_pyplot():
"""Lazily import matplotlib.pyplot (headless Agg); return it, or None if
matplotlib is unavailable. Deferred so a blocked native wheel (e.g. Windows
Smart App Control) never breaks server startup, only loss plotting.
"""
global _pyplot, _pyplot_failed
if _pyplot is not None or _pyplot_failed:
return _pyplot
try:
import matplotlib
matplotlib.use("Agg") # headless backend
import matplotlib.pyplot as plt
_pyplot = plt
except Exception as e:
_pyplot_failed = True
logger.warning("matplotlib unavailable; loss plots disabled", error = str(e))
return _pyplot
def _coerce_seed(value, default = 3407) -> int:
"""Normalize None / non-int to `default` (transformers.set_seed(None) raises)."""
@ -655,7 +680,7 @@ class TrainingBackend:
plot = self._create_loss_plot(progress, theme)
return (plot, progress)
def refresh_plot_for_theme(self, theme: str) -> Optional[plt.Figure]:
def refresh_plot_for_theme(self, theme: str) -> "Optional[plt.Figure]":
"""Refresh plot with new theme."""
if theme and isinstance(theme, str) and theme in ["light", "dark"]:
self.current_theme = theme
@ -1090,8 +1115,14 @@ class TrainingBackend:
self,
progress: TrainingProgress,
theme: str = "light",
) -> plt.Figure:
"""Create training loss plot with theme-aware styling."""
) -> "Optional[plt.Figure]":
"""Create training loss plot with theme-aware styling.
matplotlib is loaded lazily; returns None if it is unavailable.
"""
plt = _load_pyplot()
if plt is None:
return None
plt.close("all")
LIGHT_STYLE = {

View file

@ -282,6 +282,7 @@ from routes import (
training_router,
)
from routes.llama import router as llama_router
from routes.preview import router as preview_router
from hub.routes import (
inventory_router as hub_inventory_router,
datasets_router as hub_datasets_router,
@ -672,6 +673,7 @@ from utils.upload_limits import ( # noqa: E402
_BODY_PROTECTED_PREFIXES = (
"/v1/chat/completions",
"/v1/completions",
"/p/",
"/api/inference",
"/api/data-recipe",
"/api/datasets",
@ -855,24 +857,16 @@ async def _recipes_redirect(rest: str = ""):
return _RedirectResponse(url = target, status_code = 308)
_api_only = os.environ.get("UNSLOTH_API_ONLY") == "1"
_cors_origins = ["*"]
if _api_only:
_cors_origins = [
"tauri://localhost", # Linux/macOS Tauri webview
"http://tauri.localhost", # Windows Tauri webview
"http://localhost", # dev fallback
"http://localhost:5173", # Tauri dev/Vite
"http://127.0.0.1:5173", # Tauri dev/Vite fallback
]
_cors_origin_regex = None
else:
_cors_origin_regex = None
from utils.host_policy import cors_origins_for_mode # noqa: E402
_cors_origins = cors_origins_for_mode(
api_only = os.environ.get("UNSLOTH_API_ONLY") == "1",
secure = os.environ.get("UNSLOTH_SECURE") == "1",
)
app.add_middleware(
CORSMiddleware,
allow_origins = _cors_origins,
allow_origin_regex = _cors_origin_regex,
allow_credentials = True,
allow_methods = ["*"],
allow_headers = ["*"],
@ -893,6 +887,7 @@ app.include_router(inference_studio_router, prefix = "/api/inference", tags = ["
# OpenAI-compatible: mount the inference router at /v1 for external tools.
app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"])
app.include_router(preview_router, prefix = "/p", tags = ["preview"])
app.include_router(providers_router, prefix = "/api/providers", tags = ["providers"])
app.include_router(settings_router, prefix = "/api/settings", tags = ["settings"])
app.include_router(mcp_servers_router, prefix = "/api/mcp/servers", tags = ["mcp"])

View file

@ -1102,6 +1102,8 @@ class ChoiceDelta(BaseModel):
role: Optional[str] = None
content: Optional[str] = None
reasoning_content: Optional[str] = None
tool_calls: Optional[list[dict]] = None
OpenAIFinishReason = Literal["stop", "length", "tool_calls", "content_filter", "function_call"]
@ -1137,6 +1139,8 @@ class CompletionMessage(BaseModel):
role: Literal["assistant"] = "assistant"
content: str
refusal: Optional[str] = None
reasoning_content: Optional[str] = None
tool_calls: Optional[list[dict]] = None
class CompletionChoice(BaseModel):

View file

@ -601,6 +601,8 @@ class TrainingRunSummary(BaseModel):
loss_sparkline: Optional[List[float]] = None
can_resume: bool = False
resumed_later: bool = False
has_preview_model: bool = False
preview_ref: Optional[str] = None
class TrainingRunUpdateRequest(BaseModel):

View file

@ -11,10 +11,12 @@ peft==0.18.1
# TRL and related packages
trl==0.23.1
git+https://github.com/meta-pytorch/OpenEnv.git
# executorch>=1.0.1 # 41.5 MB - no imports in unsloth/zoo/studio
torch-c-dlpack-ext
sentence_transformers==5.2.0
transformers==4.57.6
pytorch_tokenizers
kernels==0.12.1
# kernels<3.11 imports tomli as its tomllib fallback; --no-deps skips its own
# marker dep, so list it here (no-op on the 3.12/3.13 default installs).
tomli; python_version < "3.11"

View file

@ -1,27 +1,11 @@
# OpenEnv dependencies
tomli
tomli-w
# ExecuTorch dependencies
ruamel.yaml
# coremltools # 10.2 MB - Apple CoreML, no imports in unsloth/zoo/studio
expecttest
# transitive dep of onnxruntime (via data-designer's pymupdf4llm)
flatbuffers
hydra-core
hypothesis
kgb
parameterized
pytest>=9.0.3,<10
pytest-json-report
pytest-rerunfailures>=16.2,<17
pytest-xdist
# Also needed by sentence_transformers (installed with --no-deps in extras-no-deps.txt)
# Also needed by sentence_transformers (installed with --no-deps in extras-no-deps.txt);
# librosa pulls it in too, but is skipped in no-torch mode.
scikit-learn==1.7.1
# Additional extras
pybind11
langid
jiwer
jiwer # WER/CER metrics for vision OCR save-merge benchmarks
omegaconf
einx
pyloudnorm
@ -39,17 +23,12 @@ ftfy
importlib-resources
librosa
markdown2
matplotlib
matplotlib==3.10.9
pystoi
soundfile
tensorboard
torch-stoi
evaluate
timm
transformers-cfg
open_spiel
addict
easydict
einops
tabulate
openai>=2.7.2

View file

@ -56,7 +56,7 @@ httpx
httpcore
certifi
idna
anyio>=3.0,<4.14.0 # one consistent <4.14: 4.14's TaskHandle importers over a stale 4.13 _core/_tasks -> ImportError (#6483)
anyio>=3.0,<4.14.0 # 4.14 asyncio cancel-scope RuntimeError on Py3.13 streaming (#6483); 4.13 unaffected
sniffio
h11

View file

@ -8,16 +8,16 @@ huggingface-hub==0.36.2
datasets==4.3.0
pyarrow==23.0.1
# FastMCP/OpenEnv compat
# FastMCP compat
fastmcp>=3.0.2
mcp>=1.24,<2
websockets>=15.0.1
# Keep anyio on one consistent <4.14 line. anyio 4.14 added TaskHandle (imported
# by __init__.py and the asyncio backend from _core/_tasks); a clean 4.14 is fine
# on 3.13. The real failure (#6483) is a half-resolved install: a stale 4.13
# _core/_tasks (no TaskHandle) under 4.14's importers raises ImportError and 500s
# the server. Global cap so later with-deps steps can't re-resolve it up.
# Cap anyio <4.14: 4.14's new asyncio per-task cancel scope (TaskHandle/_run_coro)
# gets exited in the wrong task on Python 3.13 under starlette's collapsing task
# group, raising "RuntimeError: ... exit a cancel scope that isn't the current
# task's" on streaming responses (#6483); 4.13 has no such code. Global cap so
# later with-deps steps can't re-resolve it up.
anyio<4.14.0
pandas==2.3.3

View file

@ -4,10 +4,9 @@
# happens at runtime via the side-car venvs.
transformers>=4.57.6
# mlx-vlm / mlx-lm pull anyio>=4.14, which fights the constraints.txt cap
# (anyio<4.14.0). The -c constraint loses that fight on macOS-arm, leaving a
# half-resolved anyio (4.14 importers over a stale 4.13 _core/_tasks with no
# TaskHandle) that ImportErrors and 500s the server (#6483; clean 4.14 is fine,
# it is the mix that breaks). An override wins the fight, so force one
# consistent <4.14 here too.
# mlx-vlm / mlx-lm pull anyio>=4.14, which fights the constraints.txt cap (needed
# for the 4.14 Python-3.13 streaming cancel-scope RuntimeError, #6483). The -c
# constraint loses that fight on macOS-arm, leaving a half-resolved 4.14/4.13
# anyio that also ImportErrors on TaskHandle and 500s the server. An override
# wins the fight, so force one consistent <4.14 here too.
anyio<4.14.0

View file

@ -4,13 +4,11 @@ fastapi
uvicorn
pydantic
packaging
matplotlib
matplotlib==3.10.9
pandas
nest_asyncio
datasets==4.3.0
pyjwt
easydict
addict
# gradio>=4.0.0 # 148 MB - Studio uses React + FastAPI, not Gradio
huggingface-hub==0.36.2
structlog>=24.1.0

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,196 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Per-checkpoint preview endpoints: /p/{run}[/{checkpoint}]/v1/..."""
from __future__ import annotations
import asyncio
import html
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from loggers import get_logger
from auth.authentication import get_current_subject
from auth.storage import DEFAULT_ADMIN_USERNAME
from models.inference import ChatCompletionRequest, LoadRequest
from routes.inference import load_model, openai_chat_completions
from state.tool_policy import tools_force_disabled
from utils.models.checkpoints import list_preview_targets, resolve_preview_checkpoint
logger = get_logger(__name__)
router = APIRouter()
# Public (no key); resolve_preview_checkpoint pins `run` under outputs_root.
# One model loads at a time, so serialize load+generate across previews.
_preview_lock = asyncio.Lock()
def _resolve_or_4xx(run: str, checkpoint: str | None):
try:
return resolve_preview_checkpoint(run, checkpoint)
except ValueError as exc:
# Detail can carry the absolute install path on a symlink escape; log it,
# return a generic message on this public route.
logger.warning("preview path rejected: %s", exc)
raise HTTPException(status_code = 400, detail = "Invalid run or checkpoint")
except FileNotFoundError as exc:
raise HTTPException(status_code = 404, detail = str(exc))
def _sanitize_preview_payload(
payload: ChatCompletionRequest, is_lora: bool
) -> ChatCompletionRequest:
# Public surface: strip tools/MCP + provider routing (no host code / open proxy).
# Normalize use_adapter (never trust the caller): pin True for LoRA, None for
# merged. _apply_adapter_state mutates the shared model without restoring, so an
# unpinned `false` would persist to later visitors who omit the field.
return payload.model_copy(
update = {
"tools": None,
"enable_tools": False,
"enabled_tools": None,
"mcp_enabled": False,
"bypass_permissions": False,
"confirm_tool_calls": False,
"session_id": None,
"rag_scope": None,
"openai_code_exec_container_id": None,
"anthropic_code_exec_container_id": None,
"provider_id": None,
"provider_type": None,
"external_model": None,
"encrypted_api_key": None,
"provider_base_url": None,
"use_adapter": True if is_lora else None,
}
)
async def _unlock_after(body_iterator):
# Hold the lock until the stream drains so another checkpoint can't swap mid-stream.
try:
async for chunk in body_iterator:
yield chunk
finally:
_preview_lock.release()
async def _serve_chat(
run: str, checkpoint: str | None, payload: ChatCompletionRequest, request: Request
):
path = _resolve_or_4xx(run, checkpoint)
is_lora = (path / "adapter_config.json").exists()
payload = _sanitize_preview_payload(payload, is_lora)
await _preview_lock.acquire()
keep_locked = False
try:
await load_model(LoadRequest(model_path = str(path)), request, DEFAULT_ADMIN_USERNAME)
# Beats a process-wide `--enable-tools` (enable_tools=False alone wouldn't).
with tools_force_disabled():
response = await openai_chat_completions(payload, request, DEFAULT_ADMIN_USERNAME)
if isinstance(response, StreamingResponse):
response.body_iterator = _unlock_after(response.body_iterator)
keep_locked = True
return response
finally:
if not keep_locked:
_preview_lock.release()
@router.get("")
async def list_previews(request: Request, current_subject: str = Depends(get_current_subject)):
base = str(request.base_url)
previews = []
for target in list_preview_targets():
ref = quote(target["ref"], safe = "/")
previews.append({**target, "url": f"{base}p/{ref}/v1"})
return {"object": "list", "data": previews}
@router.post("/{run}/v1/chat/completions")
async def preview_chat_latest(run: str, payload: ChatCompletionRequest, request: Request):
return await _serve_chat(run, None, payload, request)
@router.post("/{run}/{checkpoint}/v1/chat/completions")
async def preview_chat_checkpoint(
run: str, checkpoint: str, payload: ChatCompletionRequest, request: Request
):
return await _serve_chat(run, checkpoint, payload, request)
def _models_response(run: str, checkpoint: str | None):
path = _resolve_or_4xx(run, checkpoint)
model_id = run if not checkpoint else f"{run}/{checkpoint}"
return {
"object": "list",
"data": [
{
"id": model_id,
"object": "model",
"created": int(path.stat().st_mtime),
"owned_by": "unsloth-studio",
}
],
}
@router.get("/{run}/v1/models")
async def preview_models_latest(run: str):
return _models_response(run, None)
@router.get("/{run}/{checkpoint}/v1/models")
async def preview_models_checkpoint(run: str, checkpoint: str):
return _models_response(run, checkpoint)
# Serve logo/fonts here too: the SPA static mount is absent in --api-only (Tauri).
_FRONTEND_DIST = (Path(__file__).resolve().parents[2] / "frontend" / "dist").resolve()
_PREVIEW_ASSET_MEDIA_TYPES = {
".png": "image/png",
".woff": "font/woff",
".woff2": "font/woff2",
}
@router.get("/_assets/{asset_path:path}")
async def preview_asset(asset_path: str):
target = (_FRONTEND_DIST / asset_path).resolve()
media_type = _PREVIEW_ASSET_MEDIA_TYPES.get(target.suffix.lower())
if media_type is None or not target.is_relative_to(_FRONTEND_DIST) or not target.is_file():
raise HTTPException(status_code = 404, detail = "Not found")
return FileResponse(target, media_type = media_type)
# Self-contained public page; only the title is interpolated.
_PREVIEW_PAGE_HTML = (
Path(__file__).resolve().parent.parent / "assets" / "preview_page.html"
).read_text(encoding = "utf-8")
_PREVIEW_PAGE_CSP = (
"default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
"img-src 'self'; font-src 'self'; connect-src 'self'; base-uri 'none'"
)
def _preview_page(run: str, checkpoint: str | None) -> HTMLResponse:
_resolve_or_4xx(run, checkpoint)
title = run if not checkpoint else f"{run}/{checkpoint}"
page = _PREVIEW_PAGE_HTML.replace("__TITLE__", html.escape(title))
return HTMLResponse(page, headers = {"Content-Security-Policy": _PREVIEW_PAGE_CSP})
@router.get("/{run}", response_class = HTMLResponse)
async def preview_page_latest(run: str):
return _preview_page(run, None)
@router.get("/{run}/{checkpoint}", response_class = HTMLResponse)
async def preview_page_checkpoint(run: str, checkpoint: str):
return _preview_page(run, checkpoint)

View file

@ -27,6 +27,7 @@ from storage.studio_db import (
list_runs,
update_run_display_name,
)
from utils.models.checkpoints import has_preview_model, preview_ref
logger = get_logger(__name__)
@ -42,7 +43,17 @@ async def list_training_runs(
"""List training runs, newest first."""
result = list_runs(limit = limit, offset = offset)
return TrainingRunListResponse(
runs = [TrainingRunSummary(**{**r, "can_resume": can_resume_run(r)}) for r in result["runs"]],
runs = [
TrainingRunSummary(
**{
**r,
"can_resume": can_resume_run(r),
"has_preview_model": has_preview_model(r.get("output_dir")),
"preview_ref": preview_ref(r.get("output_dir")),
}
)
for r in result["runs"]
],
total = result["total"],
)
@ -67,6 +78,8 @@ async def get_training_run_detail(run_id: str, current_subject: str = Depends(ge
**{
**{k: v for k, v in run.items() if k != "config_json"},
"can_resume": can_resume_run(run),
"has_preview_model": has_preview_model(run.get("output_dir")),
"preview_ref": preview_ref(run.get("output_dir")),
}
),
config = config,
@ -98,6 +111,8 @@ async def update_training_run(
**{
**{k: v for k, v in refreshed.items() if k != "config_json"},
"can_resume": can_resume_run(refreshed),
"has_preview_model": has_preview_model(refreshed.get("output_dir")),
"preview_ref": preview_ref(refreshed.get("output_dir")),
}
)

View file

@ -340,34 +340,19 @@ def _verify_global_reachability(display_host: str, port: int) -> None:
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}",
f"{dim} Usually a cloud firewall (AWS security group, "
f"GCP firewall / Azure NSG rule) or home router isn't "
f"allowing inbound TCP {port}.{reset}",
flush = True,
)
print(
f"{dim} * GCP -- no firewall rule allowing TCP {port} "
f"for the instance's network tag.{reset}",
f"{dim} No firewall change needed -- SSH local-forward "
f"from your own computer:{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}",
f"{dim} ssh -L {port}:localhost:{port} <user>@{display_host}{reset}",
flush = True,
)
print(
@ -622,7 +607,7 @@ def _graceful_shutdown(server = None):
Windows where atexit handlers are unreliable after Ctrl+C.
"""
_remove_pid_file()
logger.info("Graceful shutdown initiated cleaning up subprocesses...")
logger.info("Graceful shutdown initiated -- cleaning up subprocesses...")
# 1. Shut down uvicorn (releases the listening socket).
if server is not None:
@ -893,9 +878,14 @@ def _setup_server_disk_logging():
def _cloudflare_tunnel_should_start(
*, cloudflare: bool, host: str, secure: bool, api_only: bool, is_colab: bool
) -> bool:
"""Whether to start the Cloudflare tunnel. --secure tunnels a loopback bind too;
non-secure keeps the 0.0.0.0-only rule. Colab/api-only never tunnel."""
return cloudflare and (host == "0.0.0.0" or secure) and not api_only and not is_colab
"""Whether to start the Cloudflare tunnel. --secure exposes only the tunnel
(loopback bind), so it tunnels even api-only (headless secure API serving);
otherwise tunnel only a 0.0.0.0 bind, never api-only (Tauri) or Colab."""
if is_colab or not cloudflare:
return False
if secure:
return True
return host == "0.0.0.0" and not api_only
def _apply_cli_tool_policy(enable_tools: "Optional[bool]") -> None:
@ -919,6 +909,7 @@ def run_server(
cloudflare: bool = True,
secure: bool = False,
enable_tools: "Optional[bool]" = None,
emit_tauri_port: bool = True,
):
"""
Start the FastAPI server.
@ -932,6 +923,9 @@ def run_server(
llama_parallel_slots: parallel slots for llama-server
enable_tools: explicit --enable-tools/--disable-tools policy; None leaves
the default (tools on, per-request enable_tools honored)
emit_tauri_port: print the machine-readable TAURI_PORT line the desktop
app parses from stdout; the headless `run --api-only` path turns it
off so it does not pollute the documented URL/API-key banner
Note:
Signal handlers are NOT registered here so embedders (e.g. Colab) keep
@ -974,9 +968,13 @@ def run_server(
if _session_log is not None and not silent:
print(f"Session log: {_session_log}")
# Set env var BEFORE importing main so CORS middleware picks it up.
# Set env vars BEFORE importing main so CORS middleware picks them up.
# secure api-only is a remote server behind Cloudflare, so it keeps the
# any-origin CORS profile; plain api-only stays locked to the Tauri app.
if api_only:
os.environ["UNSLOTH_API_ONLY"] = "1"
if secure:
os.environ["UNSLOTH_SECURE"] = "1"
import nest_asyncio
@ -1097,7 +1095,10 @@ def run_server(
app.state.server_port = port if port and port > 0 else None
# Direct (non-tunnel) base for the API panel; resolve 0.0.0.0 to the LAN IP.
if port and port > 0:
_direct_host = _resolve_external_ip() if host == "0.0.0.0" else host
_direct_host = _resolve_external_ip() if host in ("0.0.0.0", "::") else host
# Bracket IPv6 literals so the URL is valid (http://[2405:...]:port).
if ":" in _direct_host and not _direct_host.startswith("["):
_direct_host = f"[{_direct_host}]"
app.state.server_url = f"http://{_direct_host}:{port}"
else:
app.state.server_url = None
@ -1158,7 +1159,8 @@ def run_server(
atexit.register(terminate_all)
# Output port for Tauri (api-only), only after sockets bind and startup done.
if api_only:
# The headless `run --api-only` path opts out so it does not leak this line.
if api_only and emit_tauri_port:
print(f"TAURI_PORT={port}", flush = True)
# Free trycloudflare.com tunnel for 0.0.0.0 binds (the raw ip:port is often

View file

@ -3,7 +3,7 @@
"""Terminal banner for Studio startup.
Stdlib only safe to import without the rest of the backend.
Stdlib only -- safe to import without the rest of the backend.
"""
from __future__ import annotations
@ -49,10 +49,10 @@ def print_studio_stop_hint() -> None:
[
"",
style(
" To stop Unsloth Studio: press Ctrl+C in this terminal.",
" To stop Unsloth Studio: press Ctrl+C "
"(Control+C, not Command+C, on macOS).",
stop_hint_style,
),
style(" (On macOS this is Control+C, not Command+C.)", dim),
style("" * 52, dim),
"",
]
@ -101,7 +101,6 @@ def print_studio_access_banner(
# Use the loopback URL only when reachable on loopback; otherwise show
# the actual bound address.
primary_url = loopback_url if listen_all or loopback_bind else external_url
tip_url = alt_local if listen_all or loopback_bind else external_url
api_base = primary_url
lines: list[str] = [
@ -145,10 +144,6 @@ def print_studio_access_banner(
style(f" {api_base}/api", secondary),
style(f" {api_base}/api/health", secondary),
style("" * 52, dim),
style(
f" Tip: if you are on this computer, open {tip_url}/ in your browser.",
dim,
),
]
)
@ -157,23 +152,15 @@ def print_studio_access_banner(
[
"",
style(
" Studio is only reachable on this machine (bound to 127.0.0.1).",
" Reachable on this machine only (bound to 127.0.0.1).",
secondary,
),
style(
" To deploy and access globally:",
f" To expose it, stop and relaunch with: unsloth studio -H 0.0.0.0 -p {port}",
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.",
" Only on trusted networks -- anyone who reaches this machine can use Studio.",
secondary,
),
]
@ -184,10 +171,10 @@ def print_studio_access_banner(
[
"",
style(
" To stop Unsloth Studio: press Ctrl+C in this terminal.",
" To stop Unsloth Studio: press Ctrl+C "
"(Control+C, not Command+C, on macOS).",
stop_hint_style,
),
style(" (On macOS this is Control+C, not Command+C.)", dim),
style("" * 52, dim),
"",
]

View file

@ -10,15 +10,34 @@ Set by `unsloth run` at startup; consulted by the inference route gates.
False -> CLI forced tools off for every request.
"""
from typing import Optional
import contextvars
from contextlib import contextmanager
from typing import Iterator, Optional
_tool_policy: Optional[bool] = None
# Per-request hard-off so public surfaces refuse tools even under a CLI `--enable-tools`.
_force_disabled: contextvars.ContextVar[bool] = contextvars.ContextVar(
"tool_policy_force_disabled", default = False
)
def get_tool_policy() -> Optional[bool]:
if _force_disabled.get():
return False
return _tool_policy
@contextmanager
def tools_force_disabled() -> Iterator[None]:
"""Hard-disable server-side tools for the current async context."""
token = _force_disabled.set(True)
try:
yield
finally:
_force_disabled.reset(token)
def set_tool_policy(value: Optional[bool]) -> None:
if value is not None and not isinstance(value, bool):
raise TypeError(f"tool_policy must be Optional[bool], got {type(value).__name__}")

View file

@ -0,0 +1,161 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Edge cases in Gemma-native tool-call parsing.
Covers two failure modes:
1. A bare (unquoted) string argument that contains a comma, e.g.
``location:New York, NY`` -- the comma must not be treated as the next
key boundary, or the whole call is dropped.
2. A tool-call marker that appears INSIDE another call's argument string is
data, not a real call, so it must not be promoted to a second tool call.
"""
from __future__ import annotations
import json
import sys
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)
from core.inference.tool_call_parser import parse_tool_calls_from_text
def _args(call: dict) -> dict:
return json.loads(call["function"]["arguments"])
def test_bare_string_argument_with_comma_is_kept():
calls = parse_tool_calls_from_text(
"<|tool_call>call:get_weather{location:New York, NY,unit:celsius}<tool_call|>"
)
assert len(calls) == 1, calls
assert calls[0]["function"]["name"] == "get_weather"
assert _args(calls[0]) == {"location": "New York, NY", "unit": "celsius"}
def test_normal_multi_key_arguments_still_split():
calls = parse_tool_calls_from_text('<|tool_call>call:f{a:1,b:hello,c:"x,y"}<tool_call|>')
assert len(calls) == 1, calls
# Numbers stay numeric, bare strings get quoted, an explicit quoted comma
# stays inside its value.
assert _args(calls[0]) == {"a": 1, "b": "hello", "c": "x,y"}
def test_bare_value_with_timestamps_after_comma_is_kept():
# A comma followed by digits-then-colon (a timestamp/ratio) is value text,
# not a new key, so the whole query must be preserved as one argument.
calls = parse_tool_calls_from_text(
"<|tool_call>call:remind{query:meet at 10:00, 11:00 tomorrow,priority:high}<tool_call|>"
)
assert len(calls) == 1, calls
assert _args(calls[0]) == {"query": "meet at 10:00, 11:00 tomorrow", "priority": "high"}
def test_marker_inside_json_argument_is_not_a_second_call():
# A python call whose `code` argument contains a Gemma marker string. The
# marker is data and must not execute as a second `terminal` call.
content = (
'<tool_call>{"name":"python","arguments":{"code":'
'"x = 1 # <|tool_call>call:terminal{command:ls}<tool_call|>"}}</tool_call>'
)
calls = parse_tool_calls_from_text(content)
assert [c["function"]["name"] for c in calls] == ["python"], calls
def test_two_separate_gemma_calls_both_parse():
content = "<|tool_call>call:a{x:1}<tool_call|> and <|tool_call>call:b{y:2}<tool_call|>"
calls = parse_tool_calls_from_text(content)
assert [c["function"]["name"] for c in calls] == ["a", "b"], calls
assert _args(calls[0]) == {"x": 1}
assert _args(calls[1]) == {"y": 2}
def test_mixed_format_calls_preserve_document_order():
# A Gemma-native call precedes a JSON-format call in the text; tools execute
# in returned order, so `create` must come before `read`.
content = (
"<|tool_call>call:create{path:a}<tool_call|> then "
'<tool_call>{"name":"read","arguments":{"path":"a"}}</tool_call>'
)
calls = parse_tool_calls_from_text(content)
assert [c["function"]["name"] for c in calls] == ["create", "read"], calls
def test_json_marker_inside_gemma_argument_is_not_a_second_call():
# The reverse of the JSON-outer case: a JSON-style marker inside a Gemma
# call's quoted argument is code text, not a second `terminal` call.
content = (
'<|tool_call>call:python{code:<|"|>'
'print(<tool_call>{"name":"terminal","arguments":{"command":"ls"}}</tool_call>)'
'<|"|>}<tool_call|>'
)
calls = parse_tool_calls_from_text(content)
assert [c["function"]["name"] for c in calls] == ["python"], calls
def test_nested_gemma_marker_in_unquoted_arg_does_not_run_inner_call():
# An UNQUOTED Gemma value containing a literal marker: the outer object fails
# to normalize (the inner braces/marker break the JSON), but the inner marker
# is nested in the outer candidate span, so it must not be promoted to a
# standalone `terminal` call. The safe outcome is no executed tool call.
content = "<|tool_call>call:python{code:<|tool_call>call:terminal{command:ls}<tool_call|>}<tool_call|>"
calls = parse_tool_calls_from_text(content)
assert "terminal" not in [c["function"]["name"] for c in calls], calls
def test_bare_string_array_argument_is_quoted():
# Gemma may emit an array of bare strings without per-element quotes; they
# must be quoted so the call is not dropped.
calls = parse_tool_calls_from_text("<|tool_call>call:label{labels:[bug,ui]}<tool_call|>")
assert len(calls) == 1, calls
assert _args(calls[0]) == {"labels": ["bug", "ui"]}
def test_array_keeps_numbers_and_quoted_elements():
calls = parse_tool_calls_from_text(
'<|tool_call>call:f{nums:[1,2],tags:[<|"|>a,b<|"|>,c]}<tool_call|>'
)
assert _args(calls[0]) == {"nums": [1, 2], "tags": ["a,b", "c"]}
def test_array_of_objects_is_normalised():
# Arrays of objects are a common tool-schema shape; their (unquoted) keys and
# bare values must be normalised too, not left verbatim, or the call drops.
calls = parse_tool_calls_from_text(
"<|tool_call>call:batch{items:[{path:a,mode:r},{path:b,mode:w}]}<tool_call|>"
)
assert len(calls) == 1, calls
assert _args(calls[0]) == {"items": [{"path": "a", "mode": "r"}, {"path": "b", "mode": "w"}]}
def test_nested_array_elements_are_normalised():
calls = parse_tool_calls_from_text("<|tool_call>call:grid{cells:[[a,b],[c,d]]}<tool_call|>")
assert _args(calls[0]) == {"cells": [["a", "b"], ["c", "d"]]}
def test_gemma_marker_inside_xml_parameter_is_not_a_second_call():
# An XML-style <function=...> call whose <parameter=code> value contains a
# Gemma marker: the marker is the parameter's data, not a separate terminal
# call, so only the python call must be returned.
content = (
"<tool_call><function=python><parameter=code>"
"x = 1 # <|tool_call>call:terminal{command:ls}<tool_call|>"
"</parameter></function></tool_call>"
)
calls = parse_tool_calls_from_text(content)
assert [c["function"]["name"] for c in calls] == ["python"], calls
assert "terminal" in _args(calls[0])["code"]
def test_json_marker_inside_xml_parameter_is_not_a_second_call():
content = (
"<tool_call><function=python><parameter=code>"
'run(<tool_call>{"name":"terminal","arguments":{"command":"ls"}}</tool_call>)'
"</parameter></function></tool_call>"
)
calls = parse_tool_calls_from_text(content)
assert [c["function"]["name"] for c in calls] == ["python"], calls

View file

@ -0,0 +1,172 @@
# 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 `stream:false` on the GGUF agentic tool path (#6570).
When server-side tools are enabled (e.g. `unsloth studio run --model ...`,
which forces the tool policy on process-wide), a plain chat request used to be
routed into the tool loop, which returned an SSE body *regardless* of
`stream:false` -- breaking non-streaming clients and health checks like
LiteLLM. These tests drive the real route with a fake tool-capable backend and
assert the non-streaming path now returns a single JSON `chat.completion`,
while `stream:true` still streams.
"""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from auth.authentication import get_current_subject
import routes.inference as inference_route
class _ToolGgufBackend:
is_loaded = True
model_identifier = "test/model.gguf"
_is_audio = False
is_vision = False
supports_tools = True
def generate_chat_completion_with_tools(self, **kwargs):
# The agentic loop runs one tool, then the model answers. Event shapes
# mirror the real GGUF loop (tool_start/tool_end/content/metadata).
yield {
"type": "tool_start",
"tool_name": "python",
"tool_call_id": "call_1",
"arguments": {"code": "print(6 * 7)"},
}
yield {
"type": "tool_end",
"tool_name": "python",
"tool_call_id": "call_1",
"result": "42\n",
}
yield {"type": "content", "text": "The answer is 42."}
yield {
"type": "metadata",
"usage": {"prompt_tokens": 11, "completion_tokens": 5, "total_tokens": 16},
"timings": {"prompt_n": 11, "predicted_n": 5},
"finish_reason": "stop",
}
def _client(monkeypatch, backend = None):
monkeypatch.setattr(
inference_route, "get_llama_cpp_backend", lambda: backend or _ToolGgufBackend()
)
# Tools forced on -- the same effect as the CLI `run --model` tool policy.
monkeypatch.setattr(inference_route, "_effective_enable_tools", lambda payload: True)
async def _fake_select(payload, **_kwargs):
return [{"type": "function", "function": {"name": "python"}}]
monkeypatch.setattr(inference_route, "_select_request_tools", _fake_select)
app = FastAPI()
app.include_router(inference_route.router)
app.dependency_overrides[get_current_subject] = lambda: "test-user"
return TestClient(app)
def _payload(stream: bool):
return {
"messages": [{"role": "user", "content": "What is 6 * 7? Use python."}],
"stream": stream,
"enable_tools": True,
}
def test_non_streaming_tool_call_returns_single_json(monkeypatch):
response = _client(monkeypatch).post("/chat/completions", json = _payload(stream = False))
assert response.status_code == 200
# The bug returned text/event-stream here; it must be a single JSON object.
assert response.headers["content-type"].startswith("application/json")
body = response.json()
assert body["object"] == "chat.completion"
choice = body["choices"][0]
assert choice["message"]["content"] == "The answer is 42."
assert choice["finish_reason"] == "stop"
assert body["usage"]["prompt_tokens"] == 11
assert body["usage"]["completion_tokens"] == 5
assert body["usage"]["total_tokens"] == 16
def test_streaming_tool_call_still_streams(monkeypatch):
# The parallel path is untouched: stream:true keeps returning SSE.
response = _client(monkeypatch).post("/chat/completions", json = _payload(stream = True))
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/event-stream")
assert "The answer is 42." in response.text
assert "data: [DONE]" in response.text
class _EventsBackend(_ToolGgufBackend):
"""Tool backend that yields a caller-supplied event list."""
def __init__(self, events):
self._events = events
def generate_chat_completion_with_tools(self, **kwargs):
yield from self._events
def test_non_streaming_missing_usage_defaults_to_zero(monkeypatch):
# No metadata event at all: usage zero-defaults and finish_reason falls back.
events = [{"type": "content", "text": "hi"}]
response = _client(monkeypatch, _EventsBackend(events)).post(
"/chat/completions", json = _payload(stream = False)
)
assert response.status_code == 200
body = response.json()
assert body["choices"][0]["message"]["content"] == "hi"
assert body["choices"][0]["finish_reason"] == "stop"
assert body["usage"]["prompt_tokens"] == 0
assert body["usage"]["completion_tokens"] == 0
assert body["usage"]["total_tokens"] == 0
def test_non_streaming_preserves_length_finish_reason(monkeypatch):
events = [
{"type": "content", "text": "truncated"},
{
"type": "metadata",
"usage": {"prompt_tokens": 3, "completion_tokens": 9},
"finish_reason": "length",
},
]
response = _client(monkeypatch, _EventsBackend(events)).post(
"/chat/completions", json = _payload(stream = False)
)
assert response.status_code == 200
body = response.json()
assert body["choices"][0]["finish_reason"] == "length"
# total_tokens is derived when the server omits it.
assert body["usage"]["total_tokens"] == 12
def test_non_streaming_preserves_cached_tokens(monkeypatch):
# KV-cache hit details from the metadata event must survive into the body
# (the tool path used to drop them and always report cached_tokens=0).
events = [
{"type": "content", "text": "hi"},
{
"type": "metadata",
"usage": {
"prompt_tokens": 20,
"completion_tokens": 4,
"prompt_tokens_details": {"cached_tokens": 16},
},
"finish_reason": "stop",
},
]
response = _client(monkeypatch, _EventsBackend(events)).post(
"/chat/completions", json = _payload(stream = False)
)
assert response.status_code == 200
assert response.json()["usage"]["prompt_tokens_details"]["cached_tokens"] == 16

View file

@ -209,3 +209,154 @@ def test_resolve_prebuilt_linux_amd_tooling_routes_to_fork(monkeypatch, capsys):
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
assert seen["repo"] == FORK
assert out["repo"] == FORK
# Blackwell floor is sm_100 (data-center B100/B200, B300/GB300), below consumer
# sm_120 -- 120 wrongly excluded data-center hosts from the prebuilt selection.
def _gpu_linux_host(caps):
return _host(
is_linux = True,
is_x86_64 = True,
has_physical_nvidia = True,
has_usable_nvidia = True,
driver_cuda_version = (13, 1),
compute_caps = caps,
)
def test_host_is_blackwell_includes_datacenter_parts():
assert ilp._host_is_blackwell(_gpu_linux_host(["10.0"])) is True # B200 sm_100
assert ilp._host_is_blackwell(_gpu_linux_host(["10.3"])) is True # B300 sm_103
assert ilp._host_is_blackwell(_gpu_linux_host(["12.0"])) is True # RTX 50 sm_120
assert ilp._host_is_blackwell(_gpu_linux_host(["12.1"])) is True # DGX Spark sm_121
assert ilp._host_is_blackwell(_gpu_linux_host(["9.0"])) is False # Hopper
assert ilp._host_is_blackwell(_gpu_linux_host(["8.0"])) is False # Ampere
assert ilp._host_is_blackwell(_gpu_linux_host(["9.0", "10.0"])) is True # highest cap wins
def _linux_cuda_artifact(runtime_line, supported_sms, min_sm, max_sm, profile):
return ilp.PublishedLlamaArtifact(
asset_name = f"app-b9739-linux-x64-{profile}.tar.gz",
install_kind = "linux-cuda",
runtime_line = runtime_line,
coverage_class = "newer",
supported_sms = supported_sms,
min_sm = min_sm,
max_sm = max_sm,
bundle_profile = profile,
rank = 50,
)
def test_linux_blackwell_override_prefers_cuda13_for_datacenter(monkeypatch):
# Both bundles cover sm_100 and torch reports cuda12, so coverage alone can't
# decide -- only the sm_100 Blackwell floor lifts cuda13 to the front.
cuda12 = _linux_cuda_artifact(
"cuda12", ["86", "89", "90", "100", "120"], 86, 120, "cuda12-newer"
)
cuda13 = _linux_cuda_artifact(
"cuda13", ["86", "89", "90", "100", "103", "120"], 86, 120, "cuda13-newer"
)
release = ilp.PublishedReleaseBundle(
repo = FORK,
release_tag = "b9739-mix",
upstream_tag = "b9739",
assets = {cuda12.asset_name: "https://x/cuda12", cuda13.asset_name: "https://x/cuda13"},
artifacts = [cuda12, cuda13],
)
monkeypatch.setattr(
ilp,
"detected_linux_runtime_lines",
lambda: (["cuda13", "cuda12"], {"cuda13": ["/usr/lib"], "cuda12": ["/usr/lib"]}),
)
selection = ilp.linux_cuda_choice_from_release(
_gpu_linux_host(["10.0"]), release, preferred_runtime_line = "cuda12"
)
assert selection is not None
assert selection.primary.runtime_line == "cuda13"
assert selection.primary.bundle_profile == "cuda13-newer"
def test_drop_blackwell_incapable_windows_cuda_applies_to_datacenter():
# B200 (sm_100) on Windows must drop the cuda-12.4 build and keep cuda13.
host = _host(
system = "Windows",
is_windows = True,
is_x86_64 = True,
has_physical_nvidia = True,
has_usable_nvidia = True,
compute_caps = ["10.0"],
)
cuda124 = ilp.AssetChoice(
repo = FORK,
tag = "b9739",
name = "llama-b9739-bin-win-cuda-12.4-x64.zip",
url = "https://x/124",
source_label = "published",
install_kind = "windows-cuda",
)
cuda13 = ilp.AssetChoice(
repo = FORK,
tag = "b9739",
name = "app-b9739-windows-x64-cuda13-newer.zip",
url = "https://x/13",
source_label = "published",
install_kind = "windows-cuda",
max_sm = 120,
)
kept = ilp._drop_blackwell_incapable_windows_cuda(host, [cuda124, cuda13])
assert [a.name for a in kept] == [cuda13.name]
def test_blackwell_min_toolkit_is_sm_aware():
# Family floor is 12.8; sm_103/sm_121 (no native target before 12.9) lift it.
f = ilp._blackwell_min_toolkit_for_host
assert f(_gpu_linux_host(["10.0"])) == (12, 8) # B200
assert f(_gpu_linux_host(["12.0"])) == (12, 8) # RTX 50
assert f(_gpu_linux_host(["10.3"])) == (12, 9) # B300
assert f(_gpu_linux_host(["12.1"])) == (12, 9) # DGX Spark
assert f(_gpu_linux_host(["10.0", "10.3"])) == (12, 9) # max across SMs wins
def test_sm103_host_drops_cuda128_windows_build():
# B300 (sm_103) needs cuda-12.9: a legacy win-cuda-12.8 build must be dropped.
host = _host(
system = "Windows",
is_windows = True,
is_x86_64 = True,
has_physical_nvidia = True,
has_usable_nvidia = True,
compute_caps = ["10.3"],
)
cuda128 = ilp.AssetChoice(
repo = FORK,
tag = "b9739",
name = "llama-b9739-bin-win-cuda-12.8-x64.zip",
url = "https://x/128",
source_label = "published",
install_kind = "windows-cuda",
)
cuda129 = ilp.AssetChoice(
repo = FORK,
tag = "b9739",
name = "llama-b9739-bin-win-cuda-12.9-x64.zip",
url = "https://x/129",
source_label = "published",
install_kind = "windows-cuda",
)
kept = ilp._drop_blackwell_incapable_windows_cuda(host, [cuda128, cuda129])
assert [a.name for a in kept] == [cuda129.name]
# sm_100 stays on the 12.8 family floor and keeps the same 12.8 build.
b200 = _host(
system = "Windows",
is_windows = True,
is_x86_64 = True,
has_physical_nvidia = True,
has_usable_nvidia = True,
compute_caps = ["10.0"],
)
kept_b200 = ilp._drop_blackwell_incapable_windows_cuda(b200, [cuda128, cuda129])
assert [a.name for a in kept_b200] == [cuda128.name, cuda129.name]

View file

@ -68,6 +68,7 @@ _httpx_stub.Client = type(
sys.modules.setdefault("httpx", _httpx_stub)
from core.inference.llama_cpp import (
_APPLE_UNIFIED_MEMORY_FRACTION,
_CTX_FIT_VRAM_FRACTION,
LlamaCppBackend,
classify_gpu_offload_lines,
@ -120,6 +121,8 @@ def _drive(
kv_per_token_bytes = 325_000,
can_estimate_kv = True,
extra_args = None,
apple_budget_mib = 0,
flat_mtp_reserve = 0.0,
):
"""Drive the post-metadata portion of load_model with stubbed inputs.
@ -223,6 +226,32 @@ def _drive(
gpu_indices, use_fit = inst._select_gpus(model_size, gpus)
if use_fit and not explicit_ctx:
effective_ctx = min(FALLBACK_CTX, effective_ctx) if effective_ctx > 0 else FALLBACK_CTX
elif apple_budget_mib > 0 and effective_ctx > 0:
# Mirrors the Apple unified-memory branch in load_model: flat MTP reserve
# off the budget up front (no-op at 0), sparse-KV floors to FALLBACK_CTX,
# only auto context shrinks.
native_ctx_for_cap = context_length or effective_ctx
apple_fit_budget_mib = int(apple_budget_mib * max(0.0, 1.0 - flat_mtp_reserve))
if inst._can_estimate_kv():
cap = inst._fit_context_to_vram(
native_ctx_for_cap,
apple_fit_budget_mib,
model_size,
cache_type_kv,
budget_frac = 1.0,
)
cap_footprint_mib = (model_size + inst._estimate_kv_cache_bytes(cap, cache_type_kv)) / (
1024 * 1024
)
max_available_ctx = (
cap
if cap_footprint_mib <= apple_fit_budget_mib
else min(FALLBACK_CTX, native_ctx_for_cap)
)
else:
max_available_ctx = min(FALLBACK_CTX, native_ctx_for_cap)
if not explicit_ctx:
effective_ctx = max_available_ctx
return {
"c_arg": effective_ctx if effective_ctx > 0 else 0,
@ -704,3 +733,204 @@ def test_select_gpus_reserves_per_device_overhead():
small, gpus, total_by_idx = totals, per_device_overhead_bytes = gib
)
assert a == [0] and b == [0]
# ---------------------------------------------------------------------------
# Apple Silicon unified-memory context cap (#5118, #6529): no discrete GPU on
# Metal, so the auto context defaulted to native and over-committed unified
# memory. The fix budgets and caps the auto context (explicit stays verbatim).
# ---------------------------------------------------------------------------
def _force_apple(monkeypatch):
import platform as _platform
monkeypatch.setattr(_platform, "system", lambda: "Darwin")
monkeypatch.setattr(_platform, "machine", lambda: "arm64")
def _install_fake_mlx(monkeypatch, working_set_bytes):
"""Minimal mlx.core stub exposing metal.is_available() and device_info()."""
mlx = _types.ModuleType("mlx")
mlx_core = _types.ModuleType("mlx.core")
mlx_core.metal = _types.SimpleNamespace(is_available = lambda: True)
mlx_core.device_info = lambda: {"max_recommended_working_set_size": working_set_bytes}
mlx.core = mlx_core
monkeypatch.setitem(sys.modules, "mlx", mlx)
monkeypatch.setitem(sys.modules, "mlx.core", mlx_core)
class TestAppleUnifiedMemoryBudget:
def test_zero_off_apple_silicon(self, monkeypatch):
import platform as _platform
monkeypatch.setattr(_platform, "system", lambda: "Linux")
monkeypatch.setattr(_platform, "machine", lambda: "x86_64")
assert LlamaCppBackend._apple_metal_memory_budget_bytes() == 0
def test_uses_metal_working_set(self, monkeypatch):
_force_apple(monkeypatch)
ws = 27 * GIB # ~recommended working set on a 36 GB Mac
_install_fake_mlx(monkeypatch, ws)
assert LlamaCppBackend._apple_metal_memory_budget_bytes() == int(
ws * _APPLE_UNIFIED_MEMORY_FRACTION
)
def test_falls_back_to_total_ram_without_mlx(self, monkeypatch):
_force_apple(monkeypatch)
monkeypatch.setitem(sys.modules, "mlx", None) # import mlx.core -> ImportError
fake_psutil = _types.ModuleType("psutil")
fake_psutil.virtual_memory = lambda: _types.SimpleNamespace(total = 36 * GIB)
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
assert LlamaCppBackend._apple_metal_memory_budget_bytes() == int(
36 * GIB * _APPLE_UNIFIED_MEMORY_FRACTION
)
def test_zero_when_no_budget_resolvable(self, monkeypatch):
_force_apple(monkeypatch)
monkeypatch.setitem(sys.modules, "mlx", None)
monkeypatch.setitem(sys.modules, "psutil", None)
assert LlamaCppBackend._apple_metal_memory_budget_bytes() == 0
class TestAppleContextCap:
"""The real ``_fit_context_to_vram`` against the reporter's M3 Pro case."""
def test_caps_native_context_into_unified_budget(self):
# ~15.7 GB weights at native 262144 (~16 GB KV) -> ~32 GB on a 36 GB M3
# Pro (~23 GB budget); the fit must reduce the context to fit.
inst = _make_backend(native_ctx = 262144)
inst._can_estimate_kv = lambda: True
inst._estimate_kv_cache_bytes = (
lambda n, *a, **k: 0 if n <= 0 else int(n * 64_000) # ~16 GB @ 262144
)
model_size_fit = int(15.7 * GIB)
budget_mib = int(27 * GIB * _APPLE_UNIFIED_MEMORY_FRACTION) // (1024 * 1024)
# The native footprint over-commits the budget -- this is the bug.
native_footprint_mib = (model_size_fit + inst._estimate_kv_cache_bytes(262144)) // (
1024 * 1024
)
assert native_footprint_mib > budget_mib
capped = inst._fit_context_to_vram(
262144, budget_mib, model_size_fit, None, budget_frac = 1.0
)
assert capped < 262144
capped_footprint_mib = (model_size_fit + inst._estimate_kv_cache_bytes(capped)) // (
1024 * 1024
)
assert capped_footprint_mib <= budget_mib
class TestAppleBranchEndToEnd:
"""Drive the Apple elif glue (cap / floor / explicit) via _drive, no GPU."""
def test_auto_context_capped_below_native(self):
plan = _drive(
n_ctx = 0,
model_gib = 15.7,
gpus = [],
native_ctx = 262144,
kv_per_token_bytes = 64_000,
apple_budget_mib = 23_000, # ~22 GB: weights fit, native KV doesn't
)
assert 0 < plan["c_arg"] < 262144
assert plan["use_fit"] is True # --fit on still ships as a backstop
assert plan["gpu_indices"] is None # no CUDA device pinning on Metal
assert plan["max_available_ctx"] == plan["c_arg"]
def test_floors_to_fallback_when_weights_exceed_budget(self):
# Weights alone exceed budget: ctx can't help, so floor to 4096.
plan = _drive(
n_ctx = 0,
model_gib = 100,
gpus = [],
native_ctx = 262144,
apple_budget_mib = 20_000,
)
assert plan["c_arg"] == FALLBACK_CTX
assert plan["use_fit"] is True
assert plan["gpu_indices"] is None
def test_explicit_context_honored_verbatim(self):
# Explicit context is never shrunk, but the UI ceiling still tightens.
plan = _drive(
n_ctx = 200_000,
model_gib = 15.7,
gpus = [],
native_ctx = 262144,
kv_per_token_bytes = 64_000,
apple_budget_mib = 23_000,
)
assert plan["c_arg"] == 200_000 # launch context honored verbatim
assert plan["use_fit"] is True
# Ceiling reflects the budget so the over-budget warning still fires.
assert plan["max_available_ctx"] < 262144
class TestAppleMtpFlatReserve:
"""Apple cap reserves the flat MTP fraction up front (like _pin_fraction) so
an unsized MTP draft (Qwen3.6-MTP, #6529) can't over-commit."""
def test_flat_reserve_keeps_draft_within_budget(self):
# No reserve -> cap fills the budget, leaving nothing for the ~5% draft.
kw = dict(
n_ctx = 0,
model_gib = 15.7,
gpus = [],
native_ctx = 262144,
kv_per_token_bytes = 64_000,
apple_budget_mib = 23_000,
)
no_reserve = _drive(**kw, flat_mtp_reserve = 0.0)
with_reserve = _drive(**kw, flat_mtp_reserve = 0.05)
def footprint_mib(ctx):
return (15.7 * GIB + ctx * 64_000) / (1024 * 1024)
# No reserve: main footprint + 5% draft exceeds the budget.
assert footprint_mib(no_reserve["c_arg"]) + 0.05 * 23_000 > 23_000
# With reserve: the cap is smaller and the full footprint fits.
assert with_reserve["c_arg"] < no_reserve["c_arg"]
assert footprint_mib(with_reserve["c_arg"]) + 0.05 * 23_000 <= 23_000
def test_no_reserve_is_a_noop_when_mtp_absent(self):
# flat_mtp_reserve == 0 (the common, non-MTP case) must not change the cap.
kw = dict(
n_ctx = 0,
model_gib = 15.7,
gpus = [],
native_ctx = 262144,
kv_per_token_bytes = 64_000,
apple_budget_mib = 23_000,
)
assert _drive(**kw, flat_mtp_reserve = 0.0) == _drive(**kw)
class TestAppleNoKvMetadataFloor:
"""Sparse KV metadata floors the auto context to FALLBACK_CTX (like the
discrete file-size-only fallback) instead of launching at native."""
def test_sparse_kv_floors_auto_context(self):
plan = _drive(
n_ctx = 0,
model_gib = 15.7,
gpus = [],
native_ctx = 262144,
can_estimate_kv = False,
apple_budget_mib = 23_000,
)
assert plan["c_arg"] == FALLBACK_CTX # not native 262144
assert plan["use_fit"] is True
assert plan["gpu_indices"] is None
def test_sparse_kv_still_honors_explicit_context(self):
plan = _drive(
n_ctx = 100_000,
model_gib = 15.7,
gpus = [],
native_ctx = 262144,
can_estimate_kv = False,
apple_budget_mib = 23_000,
)
assert plan["c_arg"] == 100_000 # explicit honored even without KV sizing

View file

@ -77,6 +77,23 @@ def _tool_names(payload: dict) -> list[str]:
]
def _patch_monotonic(monkeypatch, values: list[float]) -> None:
import core.inference.llama_cpp as llama_cpp_mod
it = iter(values)
last = values[-1]
def fake_monotonic() -> float:
nonlocal last
try:
last = next(it)
except StopIteration:
pass
return last
monkeypatch.setattr(llama_cpp_mod.time, "monotonic", fake_monotonic)
def _structured_tool_call(tool_name: str, arguments: dict, call_id: str) -> list[str]:
return [
_sse(
@ -200,6 +217,80 @@ def test_structured_tool_call_after_visible_preface_is_executed(monkeypatch):
assert assistant_messages[-1]["tool_calls"][0]["function"]["name"] == "render_html"
def test_buffered_reasoning_answer_emits_backend_summary(monkeypatch):
stream = [
_sse({"reasoning_content": "I am thinking."}),
_sse({"reasoning_content": " Still thinking."}),
_sse({"content": "Final answer."}),
_done(),
]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [stream], payloads)
_patch_monotonic(monkeypatch, [100.0, 110.0, 172.0, 172.0])
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "answer"}],
tools = [{"type": "function", "function": {"name": "web_search"}}],
max_tool_iterations = 1,
)
)
summary_index = next(
i for i, event in enumerate(events) if event["type"] == "reasoning_summary"
)
content_index = next(i for i, event in enumerate(events) if event["type"] == "content")
assert summary_index < content_index
assert events[summary_index]["duration_ms"] == 62000
assert (
events[content_index]["text"]
== "<think>I am thinking. Still thinking.</think>Final answer."
)
def test_consumed_tool_final_pass_emits_latest_reasoning_summary(monkeypatch):
tool_stream = [
_sse({"reasoning_content": "Need a render."}),
_sse(
{
"content": '<tool_call>{"name":"render_html","arguments":{"code":"<html>ok</html>"}}</tool_call>'
}
),
_done(),
]
final_stream = [
_sse({"reasoning_content": "Now synthesize."}),
_sse({"content": "Final from tool."}),
_done(),
]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [tool_stream, final_stream], payloads)
_patch_monotonic(monkeypatch, [200.0, 201.0, 203.0, 300.0, 400.0, 405.0, 405.0])
def fake_execute_tool(name, arguments, **_kwargs):
return "Rendered HTML canvas: Done."
monkeypatch.setattr("core.inference.tools.execute_tool", fake_execute_tool)
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "render then answer"}],
tools = [{"type": "function", "function": {"name": "render_html"}}],
max_tool_iterations = 1,
)
)
summaries = [event for event in events if event["type"] == "reasoning_summary"]
assert [event["duration_ms"] for event in summaries] == [2000, 5000]
final_summary_index = events.index(summaries[-1])
final_content_index = next(
i
for i, event in enumerate(events)
if event.get("type") == "content" and "Final from tool." in event.get("text", "")
)
assert final_summary_index < final_content_index
def test_repeat_render_html_nudge_is_not_user_visible_error(monkeypatch):
"""A repeated render_html call is an internal no-op, not a visible card."""
@ -1645,3 +1736,80 @@ def test_empty_tool_call_id_does_not_emit_provisional_card(monkeypatch):
assert provisional == []
# The real call still executes despite the missing id.
assert calls == [("python", {"code": big_code})]
def _usage_done(usage: dict, finish_reason: str = "stop") -> str:
"""A terminal SSE chunk carrying llama-server's ``usage`` block, the way the
real server reports it on the final chunk of a completion."""
return (
"data: "
+ json.dumps(
{
"choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}],
"usage": usage,
}
)
+ "\n"
)
def test_metadata_event_preserves_prompt_tokens_details(monkeypatch):
"""The tool loop's metadata event must carry llama-server's
``prompt_tokens_details`` (KV-cache hits) through ``_build_metadata_event``,
so the route reports real ``cached_tokens`` instead of always 0 (#6570).
This drives the *real* generator; the route-level test feeds a pre-built
metadata event and so never exercises this code.
"""
stream = [
_sse({"content": "The answer is 42."}),
_usage_done(
{
"prompt_tokens": 20,
"completion_tokens": 4,
"prompt_tokens_details": {"cached_tokens": 16},
}
),
_done(),
]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [stream], payloads)
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "hi"}],
tools = [],
max_tool_iterations = 1,
)
)
metadata = [e for e in events if e.get("type") == "metadata"]
assert metadata, "expected a metadata event"
usage = metadata[-1]["usage"]
assert usage["prompt_tokens_details"] == {"cached_tokens": 16}
assert usage["prompt_tokens"] == 20
assert usage["completion_tokens"] == 4
def test_metadata_event_omits_prompt_tokens_details_when_absent(monkeypatch):
"""No KV-cache block from the server -> the key isn't fabricated, so the
route falls back to its 0-default instead of reading a bogus value."""
stream = [
_sse({"content": "hi"}),
_usage_done({"prompt_tokens": 5, "completion_tokens": 2}),
_done(),
]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [stream], payloads)
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "hi"}],
tools = [],
max_tool_iterations = 1,
)
)
metadata = [e for e in events if e.get("type") == "metadata"]
assert metadata, "expected a metadata event"
assert "prompt_tokens_details" not in metadata[-1]["usage"]

View file

@ -5,6 +5,7 @@ import asyncio
import os
import sys
import time
import threading
from types import SimpleNamespace
_backend = os.path.join(os.path.dirname(__file__), "..")
@ -40,6 +41,123 @@ def test_stream_first_item_deadline_after_headers():
asyncio.run(_run())
def test_stream_first_item_deadline_does_not_hop_tasks():
async def _run():
outer_task = asyncio.current_task()
seen_tasks = []
class _One:
def __init__(self):
self.done = False
async def __anext__(self):
seen_tasks.append(asyncio.current_task())
if self.done:
raise StopAsyncIteration
self.done = True
return "data: {}"
out = []
async for item in inf_mod._aiter_llama_stream_items(
_One(),
first_token_deadline = time.monotonic() + 1,
):
out.append(item)
assert out == ["data: {}"]
assert seen_tasks == [outer_task, outer_task]
asyncio.run(_run())
def test_stream_first_item_deadline_uses_compat_timeout_without_task_hop(monkeypatch):
monkeypatch.setattr(inf_mod.asyncio, "timeout", None, raising = False)
async def _run():
outer_task = asyncio.current_task()
seen_tasks = []
class _One:
def __init__(self):
self.done = False
async def __anext__(self):
seen_tasks.append(asyncio.current_task())
if self.done:
raise StopAsyncIteration
self.done = True
return "data: {}"
out = []
async for item in inf_mod._aiter_llama_stream_items(
_One(),
first_token_deadline = time.monotonic() + 1,
):
out.append(item)
assert out == ["data: {}"]
assert seen_tasks == [outer_task, outer_task]
asyncio.run(_run())
def test_stream_wait_stops_on_known_disconnect_before_read():
async def _run():
state = SimpleNamespace(disconnect_checks = 0)
cancel_event = threading.Event()
class _Request:
async def is_disconnected(self):
state.disconnect_checks += 1
return True
class _Unread:
async def __anext__(self):
raise AssertionError("stream should stop before reading upstream")
async for _ in inf_mod._aiter_llama_stream_items(
_Unread(),
cancel_event = cancel_event,
request = _Request(),
first_token_deadline = time.monotonic() + 1,
):
raise AssertionError("stream should stop after disconnect")
assert cancel_event.is_set()
assert state.disconnect_checks == 1
asyncio.run(_run())
def test_stream_wait_does_not_shorten_upstream_read_for_disconnect_poll():
async def _run():
response = SimpleNamespace(request = SimpleNamespace(extensions = {"timeout": {}}))
seen_read_timeouts = []
class _Request:
async def is_disconnected(self):
return False
class _NoItem:
async def __anext__(self):
seen_read_timeouts.append(response.request.extensions["timeout"]["read"])
raise StopAsyncIteration
async for _ in inf_mod._aiter_llama_stream_items(
_NoItem(),
cancel_event = threading.Event(),
request = _Request(),
response = response,
first_token_deadline = time.monotonic() + 1,
):
raise AssertionError("stream should end")
assert seen_read_timeouts
assert seen_read_timeouts[0] > inf_mod._STREAM_DISCONNECT_POLL_TIMEOUT_S
asyncio.run(_run())
def test_preheader_send_cleanup_on_disconnect_and_cancel():
async def _run(cancel_parent):
state = SimpleNamespace(disconnected = False, closed = False, cancelled = False)

View file

@ -423,6 +423,46 @@ def test_tool_healing_strip_handles_hyphenated_function_names():
assert out == "before after"
def test_tool_healing_strip_handles_gemma_native_tool_call():
from core.tool_healing import strip_tool_call_markup
out = strip_tool_call_markup(
'before <|tool_call>call:mcp__srv__list-issues{repo:"octocat/hello"}<tool_call|> after'
)
assert out == "before after"
def test_tool_healing_strip_handles_gemma_close_only_marker():
from core.tool_healing import strip_tool_call_markup
assert strip_tool_call_markup("before <tool_call|> after") == "before after"
assert strip_tool_call_markup("before <tool_call|> after", final = True) == "before after"
def test_tool_healing_parser_handles_gemma_native_windows_path():
from core.tool_healing import parse_tool_calls_from_text
import json as _json
calls = parse_tool_calls_from_text(
r'<|tool_call>call:ls{path:<|"|>C:\Users\wasim\repo<|"|>}<tool_call|>'
)
assert len(calls) == 1
assert calls[0]["function"]["name"] == "ls"
assert _json.loads(calls[0]["function"]["arguments"]) == {"path": r"C:\Users\wasim\repo"}
def test_tool_healing_json_parser_preserves_literal_gemma_quote_token():
from core.tool_healing import parse_tool_calls_from_text
import json as _json
text = (
"<tool_call>"
+ _json.dumps({"name": "python", "arguments": {"code": "print('<|\"|>')"}})
+ "</tool_call>"
)
calls = parse_tool_calls_from_text(text, allow_incomplete = False)
assert len(calls) == 1
assert _json.loads(calls[0]["function"]["arguments"]) == {"code": "print('<|\"|>')"}
def test_gguf_allow_list_blocks_unadvertised_tool(monkeypatch):
"""A tool call not in the per-request list must be refused by the GGUF
agentic loop (mirroring the safetensors path)."""

View file

@ -0,0 +1,100 @@
# 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 `content_to_text`, the #4383 fix for list-form message content.
Loaded by file path so the test skips importing ``core.inference`` (whose
``__init__`` pulls in the orchestrator + llama_cpp / torch).
"""
import importlib.util
from pathlib import Path
_BACKEND_DIR = Path(__file__).resolve().parent.parent
def _load_message_content():
path = _BACKEND_DIR / "core/inference/message_content.py"
spec = importlib.util.spec_from_file_location("message_content_under_test", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_string_is_returned_unchanged():
mc = _load_message_content()
assert mc.content_to_text("hello world") == "hello world"
assert mc.content_to_text("") == ""
def test_none_becomes_empty_string():
mc = _load_message_content()
assert mc.content_to_text(None) == ""
def test_single_text_part_list():
mc = _load_message_content()
content = [{"type": "text", "text": "hello"}]
assert mc.content_to_text(content) == "hello"
def test_multimodal_list_drops_non_text_parts():
mc = _load_message_content()
content = [
{"type": "text", "text": "describe this"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
]
assert mc.content_to_text(content) == "describe this"
def test_multiple_text_parts_joined_with_newline():
mc = _load_message_content()
content = [
{"type": "text", "text": "first"},
{"type": "text", "text": "second"},
]
assert mc.content_to_text(content) == "first\nsecond"
def test_bare_string_items_in_list():
mc = _load_message_content()
assert mc.content_to_text(["a", "b"]) == "a\nb"
def test_audio_and_image_only_list_is_empty():
mc = _load_message_content()
content = [
{"type": "image_url", "image_url": {"url": "x"}},
{"type": "input_audio", "input_audio": {"data": "y", "format": "wav"}},
]
assert mc.content_to_text(content) == ""
def test_part_without_type_treated_as_text():
mc = _load_message_content()
# A ``text`` field with no ``type`` is treated as text.
assert mc.content_to_text([{"text": "untyped"}]) == "untyped"
def test_empty_text_parts_skipped():
mc = _load_message_content()
content = [
{"type": "text", "text": ""},
{"type": "text", "text": "kept"},
]
assert mc.content_to_text(content) == "kept"
def test_tuple_behaves_like_list():
mc = _load_message_content()
content = ({"type": "text", "text": "x"}, {"type": "text", "text": "y"})
assert mc.content_to_text(content) == "x\ny"
def test_result_supports_string_ops():
mc = _load_message_content()
# Crux of #4383: result must be a plain str for caller .strip()/.replace().
out = mc.content_to_text([{"type": "text", "text": " padded "}])
assert out.strip() == "padded"
assert isinstance(out, str)

View file

@ -119,6 +119,68 @@ def test_repair_install_pins_transformers_and_cleans_up(monkeypatch):
assert env.get("UV_OVERRIDE", "").endswith("overrides-darwin-arm64.txt")
def test_install_requires_prebuilt_wheels(monkeypatch):
# A source distribution's PEP 517 build backend runs arbitrary code at install
# time, before the post-install stack check. The unattended self-heal must
# require pre-built wheels so a malicious resolver-selected sdist cannot execute
# during ordinary Studio startup. mlx/mlx-metal ship wheels only and
# mlx-lm/mlx-vlm publish py3-none-any wheels, so a healthy self-heal still works.
pytest.importorskip("transformers")
captured = {}
class _Result:
returncode = 0
stdout = ""
monkeypatch.setattr(mr, "_uv_executable", lambda: "/usr/bin/uv")
monkeypatch.setattr(
mr.subprocess, "run", lambda cmd, **k: captured.update(cmd = cmd) or _Result()
)
monkeypatch.setattr(mr, "mlx_stack_available", lambda: True)
assert mr.attempt_mlx_repair() is True
assert mr._ONLY_BINARY_ARG in captured["cmd"]
def test_install_env_drops_secrets_and_source_redirects(monkeypatch):
# The unattended self-heal must not hand resolver/build code the full Studio
# environment: secrets and package-source redirects are dropped, while the
# variables uv genuinely needs are forwarded.
monkeypatch.setenv("HF_TOKEN", "secret-hf")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "secret-aws")
monkeypatch.setenv("WANDB_API_KEY", "secret-wandb")
monkeypatch.setenv("UV_FIND_LINKS", "/tmp/evil")
monkeypatch.setenv("UV_DEFAULT_INDEX", "file:///tmp/evil-index")
monkeypatch.setenv("UV_INDEX_URL", "https://evil.example/simple")
monkeypatch.setenv("PIP_INDEX_URL", "https://evil.example/simple")
monkeypatch.setenv("UV_CACHE_DIR", "/tmp/evil-cache")
monkeypatch.setenv("XDG_CACHE_HOME", "/tmp/evil-xdg-cache")
monkeypatch.setenv("PATH", "/usr/bin:/bin")
monkeypatch.setenv("HOME", "/home/studio")
env = mr._mlx_install_env()
# Secrets never reach a (potentially malicious) build/install hook.
for secret in ("HF_TOKEN", "AWS_SECRET_ACCESS_KEY", "WANDB_API_KEY"):
assert secret not in env
# A poisoned process env cannot repoint the install at a hostile source or
# an attacker-staged cache (cache poisoning / symlink writes).
for redirect in (
"UV_FIND_LINKS",
"UV_DEFAULT_INDEX",
"UV_INDEX_URL",
"PIP_INDEX_URL",
"UV_CACHE_DIR",
"XDG_CACHE_HOME",
):
assert redirect not in env
# What uv genuinely needs is still forwarded.
assert env["PATH"] == "/usr/bin:/bin"
assert env["HOME"] == "/home/studio"
# UV_OVERRIDE is set by us (not inherited), so a poisoned one is ignored.
assert env.get("UV_OVERRIDE", "").endswith("overrides-darwin-arm64.txt")
def test_repair_rejects_inadequate_stack(monkeypatch):
# A successful uv run that still leaves an old/missing mlx-vlm must NOT clear
# chat-only: attempt_mlx_repair returns False so Train/Export stay disabled.
@ -276,3 +338,22 @@ def test_attempts_only_once_per_process(monkeypatch):
second = mr.start_mlx_autorepair_if_needed()
assert first is True
assert second is False # guard prevents a second concurrent attempt
def test_mlx_install_env_routes_uv_override_through_safe_path(monkeypatch):
# uv truncates UV_OVERRIDE at the first space (issue #6503).
seen = {}
def _spy(path):
seen["path"] = path
return "/space free/marker.txt".replace(" ", "_")
monkeypatch.setattr(mr, "uv_safe_path", _spy)
monkeypatch.delenv("UV_OVERRIDE", raising = False)
env = mr._mlx_install_env()
# The override file ships in the repo, so the helper must have run.
assert "path" in seen
assert str(seen["path"]).endswith("overrides-darwin-arm64.txt")
assert env["UV_OVERRIDE"] == "/space_free/marker.txt"

View file

@ -48,6 +48,7 @@ from routes.inference import (
_openai_passthrough_stream,
_openai_stream_usage_chunk,
_proxy_to_external_provider,
_SameTaskStreamingResponse,
_set_or_prepend_system_message,
openai_completions,
openai_embeddings,
@ -1245,6 +1246,79 @@ class TestGgufVisionToolRouting:
return TestGgufVisionToolRouting._drive(_consume())
@staticmethod
def _sse_payloads(chunks):
payloads = []
for chunk in chunks:
if isinstance(chunk, bytes):
chunk = chunk.decode()
for line in str(chunk).splitlines():
if not line.startswith("data: "):
continue
data = line.removeprefix("data: ")
if data == "[DONE]":
continue
try:
payloads.append(json.loads(data))
except json.JSONDecodeError:
pass
return payloads
def _run_gguf_case(
self,
monkeypatch,
*,
generate = None,
tool_generate = None,
payload_kwargs = None,
backend_kwargs = None,
):
import routes.inference as inf_mod
reset_tool_policy()
def _plain(**_kwargs):
raise AssertionError("plain GGUF path should not be used")
backend_data = {
"is_loaded": True,
"is_vision": False,
"supports_tools": tool_generate is not None,
"supports_reasoning": True,
"reasoning_always_on": True,
"_is_audio": False,
"model_identifier": "test-gguf",
"context_length": 4096,
"generate_chat_completion": generate or _plain,
}
if tool_generate is not None:
backend_data["generate_chat_completion_with_tools"] = tool_generate
if backend_kwargs:
backend_data.update(backend_kwargs)
backend = SimpleNamespace(**backend_data)
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
monkeypatch.setattr(inf_mod, "get_llama_cpp_backend", lambda: backend)
request_data = {
"model": "default",
"messages": [{"role": "user", "content": "hi"}],
}
if payload_kwargs:
request_data.update(payload_kwargs)
payload = ChatCompletionRequest(**request_data)
response = self._drive(
openai_chat_completions(payload, request = self._Request(), current_subject = "test")
)
result = SimpleNamespace(response = response, monitor = monitor, backend = backend)
if request_data.get("stream"):
result.chunks = self._consume_response(response)
result.payloads = self._sse_payloads(result.chunks)
else:
result.body = json.loads(response.body)
return result
def test_image_request_with_enabled_tools_enters_gguf_tool_loop(self, monkeypatch):
import routes.inference as inf_mod
@ -1275,6 +1349,7 @@ class TestGgufVisionToolRouting:
model = "default",
enable_tools = True,
enabled_tools = ["web_search"],
stream = True,
messages = [
{
"role": "user",
@ -1334,6 +1409,7 @@ class TestGgufVisionToolRouting:
enable_tools = True,
enabled_tools = ["web_search"],
parallel_tool_calls = False,
stream = True,
messages = [{"role": "user", "content": "search once"}],
)
@ -1390,6 +1466,152 @@ class TestGgufVisionToolRouting:
assert "confirm_tool_calls requires stream=true" in entry["error"]
assert monitor.active_count() == 0
def test_standard_gguf_stream_splits_reasoning_content(self, monkeypatch):
def _generate(**_kwargs):
yield "<thi"
yield "<think>plan"
yield "<think>plan</think>vis"
yield "<think>plan</think>visible"
yield {
"type": "metadata",
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
"finish_reason": "stop",
}
result = self._run_gguf_case(
monkeypatch,
generate = _generate,
payload_kwargs = {"stream": True},
)
deltas = [p["choices"][0].get("delta", {}) for p in result.payloads if p.get("choices")]
assert "".join(d.get("reasoning_content", "") for d in deltas) == "plan"
assert "".join(d.get("content", "") for d in deltas) == "visible"
assert all("<think>" not in d.get("content", "") for d in deltas)
[entry] = result.monitor.snapshot()
assert entry["reply"] == "visible"
def test_reasoning_capable_gguf_stream_splits_reasoning_by_default(self, monkeypatch):
def _generate(**_kwargs):
yield "<think>plan</think>visible"
yield {
"type": "metadata",
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
"finish_reason": "stop",
}
result = self._run_gguf_case(
monkeypatch,
generate = _generate,
payload_kwargs = {"stream": True},
backend_kwargs = {"reasoning_always_on": False},
)
deltas = [p["choices"][0].get("delta", {}) for p in result.payloads if p.get("choices")]
assert "".join(d.get("reasoning_content", "") for d in deltas) == "plan"
assert "".join(d.get("content", "") for d in deltas) == "visible"
[entry] = result.monitor.snapshot()
assert entry["reply"] == "visible"
def test_reasoning_capable_gguf_stream_sanitizes_think_tags_when_disabled(self, monkeypatch):
def _generate(**_kwargs):
yield "<think>leaked</think>visible"
yield {
"type": "metadata",
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
"finish_reason": "stop",
}
result = self._run_gguf_case(
monkeypatch,
generate = _generate,
payload_kwargs = {"stream": True, "enable_thinking": False},
backend_kwargs = {"reasoning_always_on": False},
)
deltas = [p["choices"][0].get("delta", {}) for p in result.payloads if p.get("choices")]
assert "".join(d.get("reasoning_content", "") for d in deltas) == "leaked"
assert "".join(d.get("content", "") for d in deltas) == "visible"
assert all("<think>" not in d.get("content", "") for d in deltas)
[entry] = result.monitor.snapshot()
assert entry["reply"] == "visible"
def test_gguf_tool_stream_splits_reasoning_and_strips_gemma_tool_marker(self, monkeypatch):
def _tools(**_kwargs):
yield {
"type": "content",
"text": '<think>plan</think>visible <|tool_call>call:terminal{command:"ls"}<tool_call|>',
}
yield {
"type": "metadata",
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
"finish_reason": "stop",
}
result = self._run_gguf_case(
monkeypatch,
tool_generate = _tools,
payload_kwargs = {
"stream": True,
"enable_tools": True,
"enabled_tools": ["terminal"],
"messages": [{"role": "user", "content": "list files"}],
},
)
deltas = [p["choices"][0].get("delta", {}) for p in result.payloads if p.get("choices")]
assert "".join(d.get("reasoning_content", "") for d in deltas) == "plan"
combined_content = "".join(d.get("content", "") for d in deltas)
assert combined_content == "visible "
assert "<|tool_call>" not in combined_content
[entry] = result.monitor.snapshot()
assert entry["reply"] == "visible "
def test_gguf_tool_stream_flushes_held_text_before_status_reset(self, monkeypatch):
def _tools(**_kwargs):
yield {"type": "content", "text": "answer <"}
yield {"type": "status", "text": ""}
yield {
"type": "metadata",
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
"finish_reason": "stop",
}
result = self._run_gguf_case(
monkeypatch,
tool_generate = _tools,
payload_kwargs = {
"stream": True,
"enable_tools": True,
"enabled_tools": ["terminal"],
"messages": [{"role": "user", "content": "say literal"}],
},
)
deltas = [p["choices"][0].get("delta", {}) for p in result.payloads if p.get("choices")]
combined_content = "".join(d.get("content", "") for d in deltas)
assert combined_content == "answer <"
[entry] = result.monitor.snapshot()
assert entry["reply"] == "answer <"
def test_non_streaming_gguf_splits_reasoning_content(self, monkeypatch):
def _generate(**_kwargs):
yield "<think>plan</think>visible"
yield {
"type": "metadata",
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
"finish_reason": "stop",
}
result = self._run_gguf_case(monkeypatch, generate = _generate)
body = result.body
message = body["choices"][0]["message"]
assert message["content"] == "visible"
assert message["reasoning_content"] == "plan"
[entry] = result.monitor.snapshot()
assert entry["reply"] == "visible"
def test_non_streaming_gguf_n_records_all_monitor_replies(self, monkeypatch):
import routes.inference as inf_mod
@ -1552,6 +1774,61 @@ class TestApiMonitorProviderAndCompletionStreams:
async def is_disconnected(self):
return False
async def _run_passthrough_stream(self, monkeypatch, lines):
import routes.inference as inf_mod
class Request:
async def is_disconnected(self):
return False
async def fake_send(*_args, **_kwargs):
return httpx.Response(200, content = b"")
async def fake_items(*_args, **_kwargs):
for line in lines:
yield line
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
monkeypatch.setattr(inf_mod, "_send_stream_with_preheader_cancel", fake_send)
monkeypatch.setattr(inf_mod, "_aiter_llama_stream_items", fake_items)
monitor_id = monitor.start(
endpoint = "/v1/chat/completions",
method = "POST",
model = "gguf",
prompt = "hi",
)
payload = ChatCompletionRequest(
model = "default",
messages = [ChatMessage(role = "user", content = "hi")],
stream = True,
tools = [
{
"type": "function",
"function": {
"name": "lookup",
"parameters": {"type": "object", "properties": {}},
},
}
],
)
response = await _openai_passthrough_stream(
Request(),
threading.Event(),
SimpleNamespace(
base_url = "http://llama.test",
context_length = 4096,
_request_reasoning_kwargs = lambda *_args, **_kwargs: None,
),
payload,
"gguf",
"chatcmpl-test",
monitor_id = monitor_id,
)
chunks = [chunk async for chunk in response.body_iterator]
return SimpleNamespace(chunks = chunks, body = "".join(chunks), monitor = monitor)
def test_external_non_streaming_json_updates_monitor(self, monkeypatch):
async def _run():
import routes.inference as inf_mod
@ -1980,6 +2257,7 @@ class TestApiMonitorProviderAndCompletionStreams:
"chatcmpl-test",
monitor_id = monitor_id,
)
assert isinstance(response, _SameTaskStreamingResponse)
iterator = response.body_iterator
first = await anext(iterator)
assert "hello" in first
@ -1997,6 +2275,88 @@ class TestApiMonitorProviderAndCompletionStreams:
asyncio.run(_run())
def test_passthrough_stream_synthesizes_missing_finish_reason(self, monkeypatch):
async def _run():
result = await self._run_passthrough_stream(
monkeypatch,
[
(
'data: {"id":"upstream","created":123,"model":"gguf",'
'"choices":[{"index":0,"delta":{"content":"hello"}}]}'
),
"data: [DONE]",
],
)
body = result.body
assert '"finish_reason":"stop"' in body.replace(" ", "")
assert "data: [DONE]" in body
assert result.monitor.active_count() == 0
asyncio.run(_run())
def test_passthrough_stream_synthesizes_tool_call_finish_reason(self, monkeypatch):
async def _run():
result = await self._run_passthrough_stream(
monkeypatch,
[
(
'data: {"id":"upstream","created":123,"model":"gguf",'
'"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,'
'"id":"call_1","type":"function","function":{"name":"lookup",'
'"arguments":"{}"}}]}}]}'
),
"data: [DONE]",
],
)
compact = result.body.replace(" ", "")
assert '"finish_reason":"tool_calls"' in compact
assert '"finish_reason":"stop"' not in compact
assert "data: [DONE]" in result.body
assert result.monitor.active_count() == 0
asyncio.run(_run())
def test_passthrough_stream_error_done_skips_synthetic_finish_reason(self, monkeypatch):
async def _run():
result = await self._run_passthrough_stream(
monkeypatch,
[
'data: {"error":{"message":"boom","type":"server_error"}}',
"data: [DONE]",
],
)
compact = result.body.replace(" ", "")
assert '"error":{"message":"boom","type":"server_error"}' in compact
assert '"finish_reason"' not in compact
assert "data: [DONE]" in result.body
[entry] = result.monitor.snapshot()
assert entry["status"] == "error"
assert entry["error"] == "boom"
assert result.monitor.active_count() == 0
asyncio.run(_run())
def test_passthrough_stream_error_eof_skips_synthetic_finish_reason(self, monkeypatch):
async def _run():
result = await self._run_passthrough_stream(
monkeypatch,
['data: {"error":{"message":"boom","type":"server_error"}}'],
)
compact = result.body.replace(" ", "")
assert '"error":{"message":"boom","type":"server_error"}' in compact
assert '"finish_reason"' not in compact
assert "data: [DONE]" not in result.body
[entry] = result.monitor.snapshot()
assert entry["status"] == "error"
assert entry["error"] == "boom"
assert result.monitor.active_count() == 0
asyncio.run(_run())
def test_passthrough_non_streaming_cancel_finalizes_monitor(self, monkeypatch):
async def _run():
import routes.inference as inf_mod
@ -2058,65 +2418,20 @@ class TestApiMonitorProviderAndCompletionStreams:
def test_passthrough_clean_eof_finalizes_monitor(self, monkeypatch):
async def _run():
import routes.inference as inf_mod
class Request:
async def is_disconnected(self):
return False
async def fake_send(*_args, **_kwargs):
return httpx.Response(200, content = b"")
async def fake_items(*_args, **_kwargs):
yield 'data: {"choices":[{"delta":{"content":"hello"}}]}'
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
monkeypatch.setattr(inf_mod, "_send_stream_with_preheader_cancel", fake_send)
monkeypatch.setattr(inf_mod, "_aiter_llama_stream_items", fake_items)
monitor_id = monitor.start(
endpoint = "/v1/chat/completions",
method = "POST",
model = "gguf",
prompt = "hi",
)
payload = ChatCompletionRequest(
model = "default",
messages = [ChatMessage(role = "user", content = "hi")],
stream = True,
tools = [
{
"type": "function",
"function": {
"name": "lookup",
"parameters": {"type": "object", "properties": {}},
},
}
],
result = await self._run_passthrough_stream(
monkeypatch,
['data: {"choices":[{"delta":{"content":"hello"}}]}'],
)
chunks = result.chunks
response = await _openai_passthrough_stream(
Request(),
threading.Event(),
SimpleNamespace(
base_url = "http://llama.test",
context_length = 4096,
_request_reasoning_kwargs = lambda *_args, **_kwargs: None,
),
payload,
"gguf",
"chatcmpl-test",
monitor_id = monitor_id,
)
chunks = []
async for chunk in response.body_iterator:
chunks.append(chunk)
assert chunks == ['data: {"choices":[{"delta":{"content":"hello"}}]}\n\n']
[entry] = monitor.snapshot()
assert chunks[0] == 'data: {"choices":[{"delta":{"content":"hello"}}]}\n\n'
compact = "".join(chunks).replace(" ", "")
assert '"finish_reason":"stop"' in compact
assert chunks[-1] == "data: [DONE]\n\n"
[entry] = result.monitor.snapshot()
assert entry["status"] == "completed"
assert entry["reply"] == "hello"
assert monitor.active_count() == 0
assert result.monitor.active_count() == 0
asyncio.run(_run())

View file

@ -0,0 +1,134 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import json
from pathlib import Path
import sys
import types as _types
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)
from utils.models.checkpoints import (
list_preview_targets,
preview_ref,
resolve_preview_checkpoint,
)
def _make_run(outputs: Path) -> tuple[Path, Path]:
run = outputs / "unsloth_SmolLM-135M_1775412608"
run.mkdir(parents = True)
(run / "adapter_config.json").write_text(
json.dumps({"base_model_name_or_path": "HuggingFaceTB/SmolLM-135M"})
)
ckpt = run / "checkpoint-60"
ckpt.mkdir()
(ckpt / "adapter_config.json").write_text(
json.dumps({"base_model_name_or_path": "HuggingFaceTB/SmolLM-135M"})
)
return run, ckpt
def _point_outputs_root_at(monkeypatch, outputs: Path) -> None:
from utils.paths import storage_roots as _sr
from utils.models import checkpoints as _ckpt
monkeypatch.setattr(_sr, "outputs_root", lambda: outputs)
# checkpoints imported outputs_root by name; patch that alias too (preview_ref uses it).
monkeypatch.setattr(_ckpt, "outputs_root", lambda: outputs)
def test_resolve_main_adapter_and_checkpoint(tmp_path: Path, monkeypatch):
outputs = tmp_path / "outputs"
run, ckpt = _make_run(outputs)
_point_outputs_root_at(monkeypatch, outputs)
assert resolve_preview_checkpoint(run.name) == run
assert resolve_preview_checkpoint(run.name, "checkpoint-60") == ckpt
def test_resolve_missing_raises_not_found(tmp_path: Path, monkeypatch):
outputs = tmp_path / "outputs"
_make_run(outputs)
_point_outputs_root_at(monkeypatch, outputs)
with pytest.raises(FileNotFoundError):
resolve_preview_checkpoint("does-not-exist")
(outputs / "empty").mkdir()
with pytest.raises(FileNotFoundError):
resolve_preview_checkpoint("empty")
def test_resolve_rejects_traversal(tmp_path: Path, monkeypatch):
outputs = tmp_path / "outputs"
_make_run(outputs)
_point_outputs_root_at(monkeypatch, outputs)
with pytest.raises(ValueError):
resolve_preview_checkpoint("..", "etc")
def test_list_preview_targets_flattens_with_latest_flag(tmp_path: Path, monkeypatch):
outputs = tmp_path / "outputs"
run, _ = _make_run(outputs)
_point_outputs_root_at(monkeypatch, outputs)
targets = list_preview_targets(str(outputs))
by_ref = {t["ref"]: t for t in targets}
assert by_ref[run.name]["is_latest"] is True
assert by_ref[run.name]["checkpoint"] is None
assert by_ref[f"{run.name}/checkpoint-60"]["is_latest"] is False
assert by_ref[f"{run.name}/checkpoint-60"]["checkpoint"] == "checkpoint-60"
assert all(t["base_model"] == "HuggingFaceTB/SmolLM-135M" for t in targets)
def test_preview_ref_flat_run_is_basename(tmp_path: Path, monkeypatch):
outputs = tmp_path / "outputs"
run, _ = _make_run(outputs)
_point_outputs_root_at(monkeypatch, outputs)
assert preview_ref(str(run)) == run.name
def test_preview_ref_preserves_one_level_nesting(tmp_path: Path, monkeypatch):
outputs = tmp_path / "outputs"
_point_outputs_root_at(monkeypatch, outputs)
nested = outputs / "experiments" / "run1"
nested.mkdir(parents = True)
(nested / "adapter_config.json").write_text("{}")
# /p route supports run/checkpoint, so a single level of nesting survives.
assert preview_ref(str(nested)) == "experiments/run1"
def test_preview_ref_none_for_unpreviewable_or_too_deep(tmp_path: Path, monkeypatch):
outputs = tmp_path / "outputs"
_point_outputs_root_at(monkeypatch, outputs)
# Missing / no model artifact -> not previewable.
assert preview_ref(None) is None
empty = outputs / "empty"
empty.mkdir(parents = True)
assert preview_ref(str(empty)) is None
# Too deep for the two-segment /p route -> no dead link.
deep = outputs / "a" / "b" / "run"
deep.mkdir(parents = True)
(deep / "adapter_config.json").write_text("{}")
assert preview_ref(str(deep)) is None
# Outside outputs_root -> None.
outside = tmp_path / "elsewhere"
outside.mkdir()
(outside / "adapter_config.json").write_text("{}")
assert preview_ref(str(outside)) is None

View file

@ -0,0 +1,293 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Security smoke for the public /p preview routes.
Exercises the route layer with a real ``preview_router`` while stubbing the
expensive model calls (``load_model`` / ``openai_chat_completions``). Covers the
public-surface guarantees: path-traversal rejection, request sanitization
(tools / provider routing / use_adapter), asset-path containment, the page CSP
header + HTML escaping, and that the preview lock is held until a streaming
response is fully drained.
"""
import asyncio
import json
from pathlib import Path
import sys
import types as _types
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# Mirror test_preview.py: the real `loggers` package pulls in heavy handlers.
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient
import routes.preview as preview
from models.inference import ChatCompletionRequest
def _make_run(outputs: Path, name: str = "demorun") -> Path:
run = outputs / name
run.mkdir(parents = True)
(run / "adapter_config.json").write_text(
json.dumps({"base_model_name_or_path": "HuggingFaceTB/SmolLM-135M"})
)
ckpt = run / "checkpoint-1"
ckpt.mkdir()
(ckpt / "adapter_config.json").write_text("{}")
return run
@pytest.fixture
def captured():
return {}
@pytest.fixture
def client(tmp_path, monkeypatch, captured):
outputs = tmp_path / "outputs"
_make_run(outputs)
# resolve_preview_checkpoint -> resolve_output_dir -> outputs_root().
from utils.paths import storage_roots as _sr
monkeypatch.setattr(_sr, "outputs_root", lambda: outputs)
async def _fake_load_model(load_req, request, subject):
captured["load_path"] = load_req.model_path
return None
async def _fake_chat(payload, request, subject):
captured["payload"] = payload
return {"ok": True}
monkeypatch.setattr(preview, "load_model", _fake_load_model)
monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat)
app = FastAPI()
app.include_router(preview.router, prefix = "/p")
app.dependency_overrides[preview.get_current_subject] = lambda: "admin"
# raise_server_exceptions=False so a 5xx surfaces as a response, not a throw.
return TestClient(app, raise_server_exceptions = False)
# ── Page rendering ────────────────────────────────────────────────────────
def test_page_renders_with_csp(client):
r = client.get("/p/demorun")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
csp = r.headers.get("content-security-policy", "")
assert "default-src 'self'" in csp
assert "base-uri 'none'" in csp
def test_page_escapes_title(tmp_path, monkeypatch, captured):
outputs = tmp_path / "outputs"
# Run dir name carries an HTML-special char; the page must escape it.
_make_run(outputs, name = "a<b")
from utils.paths import storage_roots as _sr
monkeypatch.setattr(_sr, "outputs_root", lambda: outputs)
app = FastAPI()
app.include_router(preview.router, prefix = "/p")
c = TestClient(app, raise_server_exceptions = False)
r = c.get("/p/a%3Cb")
assert r.status_code == 200
assert "a<b" not in r.text
assert "a&lt;b" in r.text
def test_models_endpoint_shape(client):
r = client.get("/p/demorun/v1/models")
assert r.status_code == 200
body = r.json()
assert body["object"] == "list"
assert body["data"][0]["id"] == "demorun"
assert body["data"][0]["owned_by"] == "unsloth-studio"
def test_list_previews_builds_urls(client, monkeypatch):
monkeypatch.setattr(
preview,
"list_preview_targets",
lambda: [{"ref": "demorun", "is_latest": True}],
)
r = client.get("/p")
assert r.status_code == 200
data = r.json()["data"]
assert data[0]["url"].endswith("/p/demorun/v1")
# ── Path traversal / containment ────────────────────────────────────────────
@pytest.mark.parametrize(
"path",
[
"/p/..", # parent segment as run
"/p/%2e%2e/etc", # encoded traversal
"/p/..%2f..%2fetc/v1/models", # encoded slash traversal
"/p/does-not-exist", # unknown run
],
)
def test_traversal_and_missing_rejected(client, path):
r = client.get(path)
assert r.status_code in (400, 404), (path, r.status_code)
def test_chat_traversal_rejected(client):
r = client.post(
"/p/..%2f..%2fetc/v1/chat/completions",
json = {"messages": [{"role": "user", "content": "hi"}]},
)
assert r.status_code in (400, 404)
# ── Asset containment ────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"asset",
[
"../../../../etc/passwd", # escapes dist
"secrets.txt", # non-allowlisted suffix
"nope.png", # allowlisted suffix but missing
],
)
def test_asset_path_contained(client, asset):
r = client.get(f"/p/_assets/{asset}")
assert r.status_code == 404
# ── Request sanitization ─────────────────────────────────────────────────────
def test_chat_payload_sanitized(client, captured):
r = client.post(
"/p/demorun/v1/chat/completions",
json = {
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "rm", "parameters": {}}}],
"enable_tools": True,
"enabled_tools": ["python"],
"mcp_enabled": True,
"bypass_permissions": True,
"provider_id": "p1",
"provider_type": "custom",
"provider_base_url": "http://evil.example/v1",
"external_model": "gpt-4o",
"use_adapter": False,
"confirm_tool_calls": True,
"session_id": "abc",
"rag_scope": {"project_id": "x"},
},
)
assert r.status_code == 200
p = captured["payload"]
assert isinstance(p, ChatCompletionRequest)
# Tools / code-exec off.
assert p.tools is None
assert p.enable_tools is False
assert p.enabled_tools is None
assert p.mcp_enabled is False
assert p.bypass_permissions is False
# Tool-loop levers neutralized regardless of the tool gate.
assert p.confirm_tool_calls is False
assert p.session_id is None
assert p.rag_scope is None
# Provider routing stripped so /p can't proxy an arbitrary endpoint.
assert p.provider_id is None
assert p.provider_type is None
assert p.provider_base_url is None
assert p.external_model is None
# Adapter pinned on for LoRA: a caller can't flip the shared backend to base.
assert p.use_adapter is True
# Loads the resolved checkpoint dir, not an attacker-supplied path.
assert captured["load_path"].endswith("demorun")
def test_merged_checkpoint_strips_use_adapter(tmp_path, monkeypatch, captured):
# Merged (non-LoRA) checkpoint: no adapter to toggle, so use_adapter -> None.
outputs = tmp_path / "outputs"
merged = outputs / "mergedrun"
merged.mkdir(parents = True)
(merged / "config.json").write_text(json.dumps({"_name_or_path": "some/base"}))
from utils.paths import storage_roots as _sr
monkeypatch.setattr(_sr, "outputs_root", lambda: outputs)
async def _fake_load(load_req, request, subject):
return None
async def _fake_chat(payload, request, subject):
captured["payload"] = payload
return {"ok": True}
monkeypatch.setattr(preview, "load_model", _fake_load)
monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat)
app = FastAPI()
app.include_router(preview.router, prefix = "/p")
c = TestClient(app, raise_server_exceptions = False)
r = c.post(
"/p/mergedrun/v1/chat/completions",
json = {"messages": [{"role": "user", "content": "hi"}], "use_adapter": False},
)
assert r.status_code == 200
assert captured["payload"].use_adapter is None
# ── Streaming lock lifetime ──────────────────────────────────────────────────
def test_streaming_holds_lock_until_drained(tmp_path, monkeypatch, captured):
outputs = tmp_path / "outputs"
_make_run(outputs)
from utils.paths import storage_roots as _sr
monkeypatch.setattr(_sr, "outputs_root", lambda: outputs)
async def _fake_load_model(load_req, request, subject):
return None
async def _gen():
yield b"data: {}\n\n"
yield b"data: [DONE]\n\n"
async def _fake_chat(payload, request, subject):
return StreamingResponse(_gen())
monkeypatch.setattr(preview, "load_model", _fake_load_model)
monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat)
async def _run():
assert not preview._preview_lock.locked()
payload = ChatCompletionRequest(messages = [{"role": "user", "content": "hi"}])
resp = await preview._serve_chat("demorun", None, payload, request = None)
# Lock must still be held: a second checkpoint must not swap the backend
# mid-stream.
assert preview._preview_lock.locked()
chunks = [c async for c in resp.body_iterator]
# Released only after the stream fully drains.
assert not preview._preview_lock.locked()
return chunks
chunks = asyncio.run(_run())
assert any(b"[DONE]" in c for c in chunks)
assert not preview._preview_lock.locked()

View file

@ -59,8 +59,10 @@ from models.inference import (
ResponsesUsage,
)
from routes.inference import (
_SameTaskStreamingResponse,
_build_chat_request,
_chat_tool_calls_to_responses_output,
_extract_responses_reasoning,
_normalise_responses_input,
_responses_tool_output_content,
_responses_non_streaming,
@ -782,6 +784,15 @@ class TestResponsesNonStreamingAdapter:
assert "<think>" not in body["output"][1]["content"][0]["text"]
assert "</think>" not in body["output"][1]["content"][0]["text"]
def test_unclosed_think_block_extracts_as_reasoning(self):
reasoning, visible = _extract_responses_reasoning(
"<think>partial plan",
parse_think_markers = True,
)
assert reasoning == "partial plan"
assert visible == ""
def test_monitor_records_translated_visible_text(self, monkeypatch):
import routes.inference as inf_mod
@ -927,6 +938,38 @@ class TestResponsesNonStreamingAdapter:
assert [item["type"] for item in body["output"]] == ["message"]
assert body["output"][0]["content"][0]["text"] == "show <think>x</think> tags"
def test_reasoning_capable_gguf_parses_think_tags_by_default(self, monkeypatch):
body = self._run_with_message(
monkeypatch,
{"content": "<think>plan</think>answer"},
llama_backend = SimpleNamespace(
is_loaded = True,
reasoning_always_on = False,
supports_reasoning = True,
),
)
assert [item["type"] for item in body["output"]] == ["reasoning", "message"]
assert body["output"][0]["content"] == [{"type": "reasoning_text", "text": "plan"}]
assert body["output"][1]["content"][0]["text"] == "answer"
def test_reasoning_capable_gguf_sanitizes_think_tags_when_disabled(self, monkeypatch):
payload = ResponsesRequest(input = "hi", reasoning = {"effort": "none"})
body = self._run_with_message(
monkeypatch,
{"content": "<think>leaked</think>answer"},
payload = payload,
llama_backend = SimpleNamespace(
is_loaded = True,
reasoning_always_on = False,
supports_reasoning = True,
),
)
assert [item["type"] for item in body["output"]] == ["reasoning", "message"]
assert body["output"][0]["content"] == [{"type": "reasoning_text", "text": "leaked"}]
assert body["output"][1]["content"][0]["text"] == "answer"
def test_structured_reasoning_content_extracts_text_parts(self, monkeypatch):
body = self._run_with_message(
monkeypatch,
@ -949,7 +992,7 @@ class TestResponsesNonStreamingAdapter:
assert [item["type"] for item in body["output"]] == ["message"]
assert body["output"][0]["content"][0]["text"] == "33"
def test_reasoning_only_is_also_visible_message_text(self, monkeypatch):
def test_reasoning_only_stays_out_of_visible_message_text(self, monkeypatch):
payload = ResponsesRequest(input = "hi", reasoning = {"effort": "high"})
body = self._run_with_message(
monkeypatch,
@ -957,9 +1000,8 @@ class TestResponsesNonStreamingAdapter:
payload = payload,
)
assert [item["type"] for item in body["output"]] == ["reasoning", "message"]
assert [item["type"] for item in body["output"]] == ["reasoning"]
assert body["output"][0]["content"][0]["text"] == "plan"
assert body["output"][1]["content"][0]["text"] == "plan"
# =====================================================================
@ -1033,6 +1075,36 @@ class TestResponsesStreamAdapter:
),
)
def test_stream_response_avoids_legacy_receive_watcher(self, monkeypatch):
self._install_stream_mock(
monkeypatch,
[{"choices": [{"delta": {"content": "33"}}]}],
)
payload = ResponsesRequest(input = "hi", stream = True)
messages = [ChatMessage(role = "user", content = "hi")]
async def run():
response = await _responses_stream(payload, messages, self._Request())
assert isinstance(response, _SameTaskStreamingResponse)
sent = []
async def receive():
raise AssertionError("Responses streams poll disconnects in the generator")
async def send(message):
sent.append(message)
await response({"type": "http", "asgi": {"spec_version": "2.3"}}, receive, send)
return sent
sent = asyncio.run(run())
assert sent[0]["type"] == "http.response.start"
body = b"".join(message.get("body", b"") for message in sent).decode()
assert "response.output_text.delta" in body
assert '"delta":"33"' in body.replace(" ", "")
def test_split_think_markers_stream_as_reasoning_and_visible_text(self, monkeypatch):
chunks = [
{"choices": [{"delta": {"content": "<thi"}}]},
@ -1286,7 +1358,7 @@ class TestResponsesStreamAdapter:
assert entry["status"] == "completed"
assert entry["reply"] == "tail"
def test_reasoning_only_fallback_updates_monitor(self, monkeypatch):
def test_reasoning_only_stream_does_not_update_visible_monitor_reply(self, monkeypatch):
import routes.inference as inf_mod
class FakeExtractor:
@ -1327,15 +1399,16 @@ class TestResponsesStreamAdapter:
lines = asyncio.run(run())
assert self._payloads(lines, "response.output_text.delta")[-1]["delta"] == "plan"
assert self._payloads(lines, "response.output_text.delta") == []
assert self._payloads(lines, "response.reasoning_text.delta")[-1]["delta"] == "plan"
[entry] = monitor.snapshot()
assert entry["status"] == "completed"
assert entry["reply"] == "plan"
assert entry["reply"] == ""
def test_literal_think_tags_stream_as_visible_text_without_reasoning_request(self, monkeypatch):
def test_reasoning_capable_gguf_stream_parses_think_tags_by_default(self, monkeypatch):
chunks = [
{"choices": [{"delta": {"content": "show <thi"}}]},
{"choices": [{"delta": {"content": "nk>x</think> tags"}}]},
{"choices": [{"delta": {"content": "<thi"}}]},
{"choices": [{"delta": {"content": "nk>plan</think>answer"}}]},
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
]
self._install_stream_mock(monkeypatch, chunks)
@ -1350,13 +1423,15 @@ class TestResponsesStreamAdapter:
reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta")
text_deltas = self._payloads(lines, "response.output_text.delta")
assert reasoning_deltas == []
assert "".join(event["delta"] for event in text_deltas) == "show <think>x</think> tags"
assert "".join(event["delta"] for event in reasoning_deltas) == "plan"
assert "".join(event["delta"] for event in text_deltas) == "answer"
completed = self._payloads(lines, "response.completed")[0]
assert [item["type"] for item in completed["response"]["output"]] == ["message"]
assert completed["response"]["output"][0]["content"][0]["text"] == (
"show <think>x</think> tags"
)
assert [item["type"] for item in completed["response"]["output"]] == [
"reasoning",
"message",
]
assert completed["response"]["output"][0]["content"][0]["text"] == "plan"
assert completed["response"]["output"][1]["content"][0]["text"] == "answer"
def test_non_reasoning_gguf_stream_keeps_literal_think_tags_visible(self, monkeypatch):
chunks = [
@ -1384,7 +1459,7 @@ class TestResponsesStreamAdapter:
"show <think>x</think> tags"
)
def test_reasoning_only_streams_as_visible_message_text(self, monkeypatch):
def test_reasoning_only_stream_stays_out_of_visible_message_text(self, monkeypatch):
chunks = [
{"choices": [{"delta": {"content": "<think>plan</think>"}}]},
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
@ -1402,14 +1477,34 @@ class TestResponsesStreamAdapter:
reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta")
text_deltas = self._payloads(lines, "response.output_text.delta")
assert "".join(event["delta"] for event in reasoning_deltas) == "plan"
assert "".join(event["delta"] for event in text_deltas) == "plan"
assert text_deltas == []
completed = self._payloads(lines, "response.completed")[0]
assert [item["type"] for item in completed["response"]["output"]] == [
"reasoning",
"message",
]
assert [item["type"] for item in completed["response"]["output"]] == ["reasoning"]
assert completed["response"]["output"][0]["content"][0]["text"] == "plan"
def test_unclosed_think_stream_stays_out_of_visible_message_text(self, monkeypatch):
chunks = [
{"choices": [{"delta": {"content": "<thi"}}]},
{"choices": [{"delta": {"content": "nk>plan"}}]},
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
]
self._install_stream_mock(monkeypatch, chunks)
payload = ResponsesRequest(input = "hi", stream = True, reasoning = {"effort": "high"})
messages = [ChatMessage(role = "user", content = "hi")]
async def run():
response = await _responses_stream(payload, messages, self._Request())
return await self._collect(response)
lines = asyncio.run(run())
reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta")
text_deltas = self._payloads(lines, "response.output_text.delta")
assert "".join(event["delta"] for event in reasoning_deltas) == "plan"
assert text_deltas == []
completed = self._payloads(lines, "response.completed")[0]
assert [item["type"] for item in completed["response"]["output"]] == ["reasoning"]
assert completed["response"]["output"][0]["content"][0]["text"] == "plan"
assert completed["response"]["output"][1]["content"][0]["text"] == "plan"
def test_structured_reasoning_content_streams_as_reasoning(self, monkeypatch):
chunks = [

View file

@ -203,6 +203,20 @@ def test_detect_safetensors_features_function_xml_format_keeps_tools_on():
assert flags["supports_tools"] is True
def test_detect_safetensors_features_gemma_native_tool_call_keeps_tools_on():
"""Gemma 4 emits <|tool_call>call:name{...}<tool_call|>, which the shared
parser now reads, so the gate must not suppress tools for it."""
from routes.inference import _detect_safetensors_features
tpl_with_gemma_native = (
"{%- if tools -%}Tool call format: "
"<|tool_call>call:name{key:value}<tool_call|>{%- endif -%}"
)
backend = SimpleNamespace(active_model_name = "unsloth/gemma-4-12b-it")
flags = _detect_safetensors_features(backend, tpl_with_gemma_native)
assert flags["supports_tools"] is True
# Qwen3.5 family pin: the live GGUF + safetensors templates both wrap tool
# calls as ``<tool_call>\n<function=name>...``. Faithful slice so the
# classifier never silently regresses for this family.

View file

@ -10,6 +10,7 @@ calls, tool-result feedback, bad-JSON heal, duplicate-call short-circuit,
``__IMAGES__`` sentinel stripping, executor errors, cancel, and the iteration cap.
"""
import json
import threading
from typing import cast
@ -62,6 +63,51 @@ class TestParser:
assert parse_tool_calls_from_text(text)[0]["function"]["name"] == "python"
assert parse_tool_calls_from_text(text, allow_incomplete = False) == []
def test_gemma_native_tool_call(self):
text = '<|tool_call>call:terminal{command:"ls -la",workdir:"."}<tool_call|>'
result = parse_tool_calls_from_text(text)
assert len(result) == 1
assert result[0]["function"]["name"] == "terminal"
args = json.loads(result[0]["function"]["arguments"])
assert args == {"command": "ls -la", "workdir": "."}
def test_gemma_native_tool_call_template_quotes(self):
text = '<|tool_call>call:web_search{query:<|"|>openai news<|"|>}<tool_call|>'
result = parse_tool_calls_from_text(text)
assert len(result) == 1
assert result[0]["function"]["name"] == "web_search"
assert json.loads(result[0]["function"]["arguments"]) == {"query": "openai news"}
def test_gemma_native_tool_call_template_quotes_escape_backslashes(self):
text = r'<|tool_call>call:ls{path:<|"|>C:\Users\wasim\repo<|"|>}<tool_call|>'
result = parse_tool_calls_from_text(text)
assert len(result) == 1
assert result[0]["function"]["name"] == "ls"
assert json.loads(result[0]["function"]["arguments"]) == {"path": r"C:\Users\wasim\repo"}
def test_gemma_native_tool_call_hyphenated_argument_name(self):
text = '<|tool_call>call:mcp__srv__create-issue{issue-title:"Bug report"}<tool_call|>'
result = parse_tool_calls_from_text(text)
assert len(result) == 1
assert result[0]["function"]["name"] == "mcp__srv__create-issue"
assert json.loads(result[0]["function"]["arguments"]) == {"issue-title": "Bug report"}
def test_gemma_native_tool_call_keeps_braces_inside_string_value(self):
text = '<|tool_call>call:terminal{command:"echo {foo:bar}"}<tool_call|>'
result = parse_tool_calls_from_text(text)
assert len(result) == 1
assert result[0]["function"]["name"] == "terminal"
assert json.loads(result[0]["function"]["arguments"]) == {"command": "echo {foo:bar}"}
def test_gemma_native_tool_call_bare_string_values(self):
text = "<|tool_call>call:get_weather{location:Tokyo,unit:celsius}<tool_call|>"
result = parse_tool_calls_from_text(text)
assert len(result) == 1
assert json.loads(result[0]["function"]["arguments"]) == {
"location": "Tokyo",
"unit": "celsius",
}
def test_xml_function_call(self):
text = "<function=python><parameter=code>print('hi')</parameter></function>"
result = parse_tool_calls_from_text(text)
@ -121,6 +167,7 @@ class TestParser:
def test_has_tool_signal(self):
assert has_tool_signal("blah <tool_call> x")
assert has_tool_signal("blah <|tool_call>call:terminal")
assert has_tool_signal("hi <function=foo>...")
assert not has_tool_signal("hello world")
@ -139,6 +186,8 @@ class TestParser:
def test_strip_markup_closed(self):
text = "before <tool_call>{}</tool_call> after"
assert strip_tool_markup(text) == "before after"
text = 'before <|tool_call>call:terminal{command:"ls"}<tool_call|> after'
assert strip_tool_markup(text) == "before after"
def test_strip_markup_unclosed_final(self):
text = "before <tool_call>{partial"
@ -146,6 +195,7 @@ class TestParser:
assert strip_tool_markup(text, final = True) == "before"
# Without final=True the unclosed run is preserved.
assert "partial" in strip_tool_markup(text)
assert strip_tool_markup("before <|tool_call>call:terminal{", final = True) == "before"
def test_streaming_strip_respects_disabled_healing(self):
raw = 'before <tool_call>{"name":"web_search"'

View file

@ -31,11 +31,14 @@ from run import _cloudflare_tunnel_should_start as should_start # noqa: E402
# --no-cloudflare always wins.
(False, "0.0.0.0", False, False, False, False),
(False, "127.0.0.1", True, False, False, False),
# api-only and Colab never tunnel.
# Non-secure api-only never tunnels (Tauri).
(True, "0.0.0.0", False, True, False, False),
(True, "127.0.0.1", True, True, False, False),
# --secure tunnels even api-only (headless secure API server).
(True, "127.0.0.1", True, True, False, True),
# Colab never tunnels, even --secure.
(True, "0.0.0.0", False, False, True, False),
(True, "127.0.0.1", True, False, True, False),
(True, "127.0.0.1", True, True, True, False),
],
)
def test_cloudflare_gate(cloudflare, host, secure, api_only, is_colab, expected):
@ -162,3 +165,46 @@ def test_failclosed_message_present_in_source():
"A secure Cloudflare link is not allowed, use --no-secure which provides a 0.0.0.0 link"
in src
)
@pytest.mark.parametrize(
"api_only,secure,expected",
[
(False, False, ["*"]), # plain server: any origin
(False, True, ["*"]), # secure UI server: any origin
(True, True, ["*"]), # secure api-only: remote browsers need any origin
(True, False, "tauri"), # local api-only: locked to the Tauri app
],
)
def test_cors_origins_for_mode(api_only, secure, expected):
from utils.host_policy import cors_origins_for_mode
origins = cors_origins_for_mode(api_only = api_only, secure = secure)
if expected == "tauri":
assert origins != ["*"] and any(o.startswith("tauri://") for o in origins)
else:
assert origins == expected
def test_run_server_exports_secure_env_for_cors():
# run_server must export UNSLOTH_SECURE before importing main so the CORS
# profile can tell remote secure serving from local Tauri use.
src = (_BACKEND / "run.py").read_text(encoding = "utf-8")
assert 'os.environ["UNSLOTH_SECURE"] = "1"' in src
def test_run_server_emit_tauri_port_defaults_on():
# Default on keeps the desktop app's stdout contract; the headless
# `run --api-only` path opts out explicitly.
import inspect
import run
params = inspect.signature(run.run_server).parameters
assert "emit_tauri_port" in params
assert params["emit_tauri_port"].default is True
def test_tauri_port_print_is_gated_in_source():
# The TAURI_PORT line must depend on emit_tauri_port, not api_only alone.
src = (_BACKEND / "run.py").read_text(encoding = "utf-8")
assert "if api_only and emit_tauri_port:" in src

View file

@ -106,6 +106,54 @@ class TestParityWithJsonStyle:
assert json.loads(js[0]["function"]["arguments"]) == {"query": q}
class TestGemmaNativeStyle:
def test_closed_native_call_with_trailing_prose_is_accepted(self):
text = (
'<|tool_call>call:terminal{command:"ls -la",workdir:"."}<tool_call|>' " running it now"
)
calls = parse_tool_calls_from_text(text, allow_incomplete = False)
assert len(calls) == 1
assert calls[0]["function"]["name"] == "terminal"
assert json.loads(calls[0]["function"]["arguments"]) == {
"command": "ls -la",
"workdir": ".",
}
def test_unclosed_native_call_requires_healing(self):
text = '<|tool_call>call:terminal{command:"ls"}'
assert parse_tool_calls_from_text(text, allow_incomplete = False) == []
calls = parse_tool_calls_from_text(text, allow_incomplete = True)
assert len(calls) == 1
assert calls[0]["function"]["name"] == "terminal"
def test_hyphenated_native_argument_name_is_accepted(self):
text = '<|tool_call>call:mcp__srv__create-issue{issue-title:"Bug report"}<tool_call|>'
calls = parse_tool_calls_from_text(text, allow_incomplete = False)
assert len(calls) == 1
assert calls[0]["function"]["name"] == "mcp__srv__create-issue"
assert json.loads(calls[0]["function"]["arguments"]) == {"issue-title": "Bug report"}
def test_native_template_quotes_preserve_windows_path(self):
text = r'<|tool_call>call:ls{path:<|"|>C:\Users\wasim\repo<|"|>}<tool_call|>'
calls = parse_tool_calls_from_text(text, allow_incomplete = False)
assert len(calls) == 1
assert json.loads(calls[0]["function"]["arguments"]) == {"path": r"C:\Users\wasim\repo"}
def test_bare_unquoted_string_values_are_accepted(self):
# Gemma can emit enum/string args unquoted; bare JSON scalars stay typed.
text = (
"<|tool_call>call:get_weather{location:Tokyo,unit:celsius,days:3,live:true}<tool_call|>"
)
calls = parse_tool_calls_from_text(text, allow_incomplete = False)
assert len(calls) == 1
assert json.loads(calls[0]["function"]["arguments"]) == {
"location": "Tokyo",
"unit": "celsius",
"days": 3,
"live": True,
}
class TestHealingPathUnaffected:
def test_auto_heal_still_repairs_unclosed_function(self):
text = "<function=web_search><parameter=query>cats"

View file

@ -125,6 +125,14 @@ def test_strips_orphan_closing_tag():
# Mid-string </parameter> intentionally preserved (see preserve test).
def test_strips_gemma_native_orphan_closing_tag():
cleaned = _TOOL_XML_RE.sub("", "Tool call drained.<tool_call|>Visible tail.")
assert "<tool_call|>" not in cleaned
assert "Tool call drained." in cleaned
assert "Visible tail." in cleaned
# ── Tail-only </parameter> (PR #5735 follow-up) ───────────────────

View file

@ -69,3 +69,14 @@ def test_default_spec_matches_table(monkeypatch):
mod = _load_module(monkeypatch)
assert mod._TORCHAO_DEFAULT_SPEC == "torchao==0.14.0"
assert mod._select_torchao_spec("2.9.0") == mod._TORCHAO_DEFAULT_SPEC
def test_skips_torchao_on_windows_rocm():
"""The overrides step must skip torchao on Windows ROCm: no working build exists
there (it imports an absent c10d backend and crashes transformers.quantizers),
so the installer skips it and relies on the runtime stub instead."""
source = _INSTALL_SCRIPT.read_text(encoding = "utf-8")
# Branches on the Windows-ROCm marker set by _ensure_rocm_torch ...
assert "elif _rocm_windows_torch_installed:" in source
# ... and reports the skip in the progress label.
assert "dependency overrides (skipped, Windows ROCm)" in source

View file

@ -41,8 +41,20 @@ from utils.models.model_config import (
@pytest.fixture(autouse = True)
def _clear_vision_cache():
"""Ensure every test starts with a fresh cache."""
def _clear_vision_cache(tmp_path, monkeypatch):
"""Ensure every test starts with a fresh cache, from an empty working dir.
``is_vision_model`` calls ``is_local_path`` first: any relative model id that
happens to exist on disk (``Path(name).exists()``) is treated as a local
model, short-circuiting before the mocked detection internals run. The CI cwd
(``studio/backend``) and the HF cache can contain dirs whose names collide
with the synthetic remote ids used here (``org/my-vlm``, ``model-a``,
``broken/model`` ...), which made these tests fail with "called 0 times".
Running each test from a fresh empty ``tmp_path`` removes that collision
while leaving the real ``is_local_path`` logic intact (the local-GGUF tests
pass absolute ``tmp_path`` paths, unaffected by cwd).
"""
monkeypatch.chdir(tmp_path)
_vision_detection_cache.clear()
yield
_vision_detection_cache.clear()

View file

@ -125,6 +125,12 @@ def is_anthropic_path(path: str) -> bool:
return path.startswith("/v1/messages")
def wants_api_error_envelope(path: str) -> bool:
"""True for the OpenAI/Anthropic-compatible surfaces: the ``/v1/*`` mount and
the preview ``/p/<run>[/<ckpt>]/v1/*`` mount."""
return path.startswith("/v1/") or (path.startswith("/p/") and "/v1/" in path)
def error_body_for_path(
path,
message,
@ -183,15 +189,16 @@ def _summarize_validation_errors(errors) -> tuple:
def install_api_error_handlers(app) -> None:
"""Register validation + HTTPException handlers that emit ``/v1/*`` envelopes.
Both handlers are global but only transform responses for paths starting with
``/v1/``. Non-``/v1/`` paths reproduce FastAPI's default ``{"detail": ...}``
behavior exactly so the Studio frontend keeps working.
Both handlers are global but only transform responses for OpenAI/Anthropic-
compatible surfaces (see :func:`wants_api_error_envelope`: the ``/v1/*`` mount
and the preview ``/p/.../v1/*`` mount). Every other path reproduces FastAPI's
default ``{"detail": ...}`` behavior exactly so the Studio frontend keeps working.
"""
@app.exception_handler(RequestValidationError)
async def _handle_validation_error(request, exc):
path = request.url.path
if path.startswith("/v1/"):
if wants_api_error_envelope(path):
summary, param = _summarize_validation_errors(exc.errors())
return JSONResponse(
status_code = 400,
@ -211,7 +218,7 @@ def install_api_error_handlers(app) -> None:
# default http_exception_handler, which returns a bodiless Response.
if not is_body_allowed_for_status_code(exc.status_code):
return Response(status_code = exc.status_code, headers = headers)
if path.startswith("/v1/"):
if wants_api_error_envelope(path):
detail = exc.detail
# Already a fully-formed envelope: pass through untouched.
if isinstance(detail, dict) and ("error" in detail or detail.get("type") == "error"):

View file

@ -34,6 +34,26 @@ def is_external_host(host: str) -> bool:
return host.lower() not in _LOOPBACK_HOSTS
# Tauri desktop webview origins. api-only serving (the desktop app calling a
# local backend) locks CORS to these.
_TAURI_CORS_ORIGINS = (
"tauri://localhost", # Linux/macOS Tauri webview
"http://tauri.localhost", # Windows Tauri webview
"http://localhost", # dev fallback
"http://localhost:5173", # Tauri dev/Vite
"http://127.0.0.1:5173", # Tauri dev/Vite fallback
)
def cors_origins_for_mode(*, api_only: bool, secure: bool) -> list[str]:
"""Allowed CORS origins. Default is any-origin (["*"]); api-only locks down
to the Tauri desktop app, except in secure mode where the API is published
over Cloudflare and must stay reachable from remote browser origins."""
if api_only and not secure:
return list(_TAURI_CORS_ORIGINS)
return ["*"]
def apply_stdio_mcp_loopback_default(host: str, *, is_colab: bool = False) -> None:
"""Default stdio MCP servers on when bound to loopback.

View file

@ -36,6 +36,8 @@ from pathlib import Path
import structlog
from utils.uv_path_safety import uv_safe_path
logger = structlog.get_logger(__name__)
DISABLE_ENV_VAR = "UNSLOTH_DISABLE_MLX_AUTOREPAIR"
@ -49,6 +51,56 @@ MLX_PACKAGES = tuple(f"{name}>={version}" for name, version in _MLX_MIN_VERSIONS
_MLX_REINSTALL_ARGS = tuple(
arg for name in _MLX_PACKAGE_NAMES for arg in ("--reinstall-package", name)
)
# Require pre-built wheels for the unattended self-heal. A source distribution's
# PEP 517 build backend runs arbitrary code at install time, and this install is
# default-on, resolver-driven, and runs before the post-install stack check can
# reject anything. mlx/mlx-metal ship wheels only (no sdist on PyPI) and
# mlx-lm/mlx-vlm publish py3-none-any wheels, so requiring wheels does not break a
# healthy self-heal; if a wheel is genuinely unavailable the install fails and
# Studio stays chat-only (the existing safe fallback) until `unsloth studio update`.
_ONLY_BINARY_ARG = "--only-binary=:all:"
# Allowlist of environment variables forwarded to the install subprocess. The
# self-heal runs without confirmation on the default startup path, so it must not
# hand resolver/build code the full Studio environment. Everything outside this
# set is dropped, which excludes three dangerous classes by construction:
# * secrets (HF_TOKEN, AWS_*, WANDB_API_KEY, ...) that a malicious wheel/sdist
# build hook would otherwise read straight out of os.environ;
# * package-source redirects (UV_INDEX*, UV_DEFAULT_INDEX, UV_FIND_LINKS,
# PIP_INDEX_URL, ...) so a poisoned process env cannot silently repoint the
# install at an attacker-controlled index/find-links;
# * cache-dir redirects (UV_CACHE_DIR, XDG_CACHE_HOME) so a poisoned env cannot
# point uv at an attacker-staged cache (cache poisoning / symlink writes). uv
# falls back to its safe user-owned default cache, reused across runs anyway.
# uv still honours on-disk config (uv.toml / pip.conf), so a corporate mirror
# configured there keeps working; only process-env redirects are dropped. We set
# UV_OVERRIDE ourselves in _mlx_install_env, so a poisoned one here is ignored.
_MLX_ENV_ALLOWLIST = frozenset(
{
"PATH",
"HOME",
"USER",
"LOGNAME",
"TMPDIR",
"TMP",
"TEMP",
"LANG",
"LC_ALL",
"LC_CTYPE",
# proxies + custom CA bundles so installs behind a corporate gateway work
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
"all_proxy",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
}
)
_REPAIR_TIMEOUT_S = 900
# Attempt at most once per process; success is sticky (mlx then imports and the
@ -134,13 +186,22 @@ def _uv_install_cmd(*args: str) -> list[str] | None:
def _mlx_install_env() -> dict[str, str]:
"""Environment for the mlx install. Mirror the main installer
(install_python_stack.py) by pointing UV_OVERRIDE at overrides-darwin-arm64.txt,
which relaxes mlx-vlm/mlx-lm's transformers>=5 requirement to >=4.57.6. Without
it, uv keeps the Studio transformers pin only by silently backtracking mlx-vlm
to an old, unsupported version (uv honours UV_OVERRIDE; plain pip ignores it,
so the transformers constraint below is the pip-path safety net)."""
env = dict(os.environ)
"""Minimal, allowlisted environment for the unattended mlx install.
The self-heal runs without confirmation on the default startup path, so it
forwards only the variables uv genuinely needs (see _MLX_ENV_ALLOWLIST) instead
of the full Studio environment: secrets and package-source redirects in
os.environ are dropped so a malicious resolver-selected artifact cannot read
Studio secrets or be steered to a hostile index.
Mirror the main installer (install_python_stack.py) by pointing UV_OVERRIDE at
overrides-darwin-arm64.txt, which relaxes mlx-vlm/mlx-lm's transformers>=5
requirement to >=4.57.6. Without it, uv keeps the Studio transformers pin only
by silently backtracking mlx-vlm to an old, unsupported version (uv honours
UV_OVERRIDE; plain pip ignores it, so the transformers constraint below is the
pip-path safety net). We set UV_OVERRIDE ourselves, so a poisoned one in the
process env is ignored."""
env = {key: os.environ[key] for key in _MLX_ENV_ALLOWLIST if key in os.environ}
override = (
Path(__file__).resolve().parents[1]
/ "requirements"
@ -148,7 +209,8 @@ def _mlx_install_env() -> dict[str, str]:
/ "overrides-darwin-arm64.txt"
)
if override.is_file():
env.setdefault("UV_OVERRIDE", str(override))
# uv truncates UV_OVERRIDE at the first space (issue #6503).
env.setdefault("UV_OVERRIDE", uv_safe_path(override))
return env
@ -191,7 +253,13 @@ def attempt_mlx_repair(*, timeout: int = _REPAIR_TIMEOUT_S) -> bool:
constraint_path = None
try:
constraint_args, constraint_path = _transformers_constraint_args()
cmd = _uv_install_cmd("--upgrade", *_MLX_REINSTALL_ARGS, *constraint_args, *MLX_PACKAGES)
cmd = _uv_install_cmd(
"--upgrade",
_ONLY_BINARY_ARG,
*_MLX_REINSTALL_ARGS,
*constraint_args,
*MLX_PACKAGES,
)
if cmd is None:
logger.warning(
"MLX self-heal requires uv so Studio can apply dependency overrides; "

View file

@ -4,6 +4,7 @@
"""Checkpoint scanning utilities for discovering training runs and checkpoints."""
import json
import re
import structlog
from loggers import get_logger
from pathlib import Path
@ -12,6 +13,22 @@ from utils.paths import outputs_root, resolve_output_dir
logger = get_logger(__name__)
_CHECKPOINT_STEP_RE = re.compile(r"^checkpoint-(\d+)$")
def _checkpoint_step(checkpoint_name: str) -> Optional[int]:
match = _CHECKPOINT_STEP_RE.fullmatch(checkpoint_name)
if match is None:
return None
return int(match.group(1))
def _checkpoint_sort_key(checkpoint_path: Path) -> tuple[int, int, str]:
step = _checkpoint_step(checkpoint_path.name)
if step is not None:
return (0, -step, checkpoint_path.name)
return (1, 0, str(checkpoint_path))
def _read_checkpoint_loss(checkpoint_path: Path) -> Optional[float]:
"""Read loss from the last log_history entry of trainer_state.json, or None."""
@ -37,8 +54,10 @@ def scan_checkpoints(
Returns:
[(model_name, [(display_name, checkpoint_path, loss), ...], metadata), ...]
metadata keys (optional): base_model, peft_type, lora_rank.
First checkpoint entry is the main adapter; its loss mirrors the last
(highest-step) intermediate checkpoint.
First checkpoint entry is the main adapter; its loss mirrors the latest
(highest-step) intermediate checkpoint. Numbered checkpoints are sorted
by numeric step descending; non-numbered checkpoint-* dirs keep the
previous lexicographic directory order.
"""
models = []
outputs_path = resolve_output_dir(outputs_dir)
@ -103,18 +122,25 @@ def scan_checkpoints(
checkpoints.append((item.name, str(item), None))
# Scan for intermediate checkpoints (checkpoint-N subdirs).
for sub in sorted(item.iterdir()):
valid_checkpoints = []
for sub in item.iterdir():
if not sub.is_dir() or not sub.name.startswith("checkpoint-"):
continue
sub_config = sub / "config.json"
sub_adapter = sub / "adapter_config.json"
if sub_config.exists() or sub_adapter.exists():
loss = _read_checkpoint_loss(sub)
checkpoints.append((sub.name, str(sub), loss))
valid_checkpoints.append(sub)
# Assign the last checkpoint's loss to the main adapter entry.
if len(checkpoints) > 1:
last_checkpoint_loss = checkpoints[-1][2]
intermediate_checkpoints = []
for sub in sorted(valid_checkpoints, key = _checkpoint_sort_key):
loss = _read_checkpoint_loss(sub)
intermediate_checkpoints.append((sub.name, str(sub), loss))
checkpoints.extend(intermediate_checkpoints)
# Assign the latest checkpoint's loss to the main adapter entry.
if intermediate_checkpoints:
last_checkpoint_loss = intermediate_checkpoints[0][2]
checkpoints[0] = (
checkpoints[0][0],
checkpoints[0][1],
@ -133,3 +159,64 @@ def scan_checkpoints(
except Exception as e:
logger.error(f"Error scanning checkpoints: {e}")
return []
def _is_model_dir(path: Path) -> bool:
return (path / "config.json").exists() or (path / "adapter_config.json").exists()
def has_preview_model(output_dir: Optional[str]) -> bool:
"""True when ``output_dir`` holds a previewable root model (what ``/p/{run}``
resolves). A cancelled run keeps ``output_dir`` but saves no root adapter."""
if not output_dir:
return False
path = Path(output_dir)
return path.is_dir() and _is_model_dir(path)
def preview_ref(output_dir: Optional[str]) -> Optional[str]:
"""``/p`` ref (``run`` or ``run/checkpoint``) relative to outputs_root, or None.
Posix-joined so a nested output dir keeps a working link instead of collapsing
to its basename. None when not previewable, outside outputs_root, or deeper than
the two path segments the ``/p`` route matches (so the UI omits a dead link).
"""
if not has_preview_model(output_dir):
return None
try:
rel = Path(output_dir).resolve().relative_to(outputs_root().resolve())
except (ValueError, OSError):
return None
parts = rel.parts
if not parts or len(parts) > 2:
return None
return "/".join(parts)
def resolve_preview_checkpoint(run: str, checkpoint: Optional[str] = None) -> Path:
relative = run if not checkpoint else f"{run}/{checkpoint}"
path = resolve_output_dir(relative)
if not path.is_dir() or not _is_model_dir(path):
raise FileNotFoundError(
f"No trained checkpoint at '{relative}'. Check the run/checkpoint name (see GET /p)."
)
return path
def list_preview_targets(outputs_dir: str = str(outputs_root())) -> List[dict]:
targets: List[dict] = []
for run_name, checkpoints, metadata in scan_checkpoints(outputs_dir):
for display_name, path, loss in checkpoints:
is_latest = display_name == run_name
checkpoint = None if is_latest else Path(path).name
targets.append(
{
"run": run_name,
"checkpoint": checkpoint,
"ref": run_name if is_latest else f"{run_name}/{checkpoint}",
"is_latest": is_latest,
"loss": loss,
"base_model": metadata.get("base_model"),
}
)
return targets

View file

@ -0,0 +1,66 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Hand uv a space-free `-c`/`--override`/`-r` file path (issue #6503).
uv splits `-c`/`--override` (and UV_OVERRIDE) on whitespace, so a path with a
space truncates. Windows uses the 8.3 short form; POSIX copies the file into a
space-free temp dir (removed at exit). Falls back to the original path on error.
Shared by install_python_stack and utils.mlx_repair.
"""
from __future__ import annotations
import atexit
import os
import platform
import shutil
import tempfile
IS_WINDOWS = platform.system() == "Windows"
_UV_SAFE_PATH_TMPDIRS: list[str] = []
@atexit.register
def _cleanup_uv_safe_path_tmpdirs() -> None:
while _UV_SAFE_PATH_TMPDIRS:
shutil.rmtree(_UV_SAFE_PATH_TMPDIRS.pop(), ignore_errors = True)
def uv_safe_path(path: object) -> str:
s = str(path)
if " " not in s:
return s
if IS_WINDOWS:
try:
import ctypes
from ctypes import wintypes
get_short = ctypes.windll.kernel32.GetShortPathNameW
get_short.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD]
get_short.restype = wintypes.DWORD
buf = ctypes.create_unicode_buffer(32768)
rc = get_short(s, buf, 32768)
if 0 < rc < 32768 and " " not in buf.value:
return buf.value
except Exception:
pass
return s
tmp_dir = None
try:
if not os.path.isfile(s):
return s
tmp_dir = tempfile.mkdtemp(prefix = "unsloth_uv_")
if " " in tmp_dir: # e.g. TMPDIR itself has a space
shutil.rmtree(tmp_dir, ignore_errors = True)
return s
dst = os.path.join(tmp_dir, (os.path.basename(s) or "uv_args.txt").replace(" ", "_"))
shutil.copyfile(s, dst)
_UV_SAFE_PATH_TMPDIRS.append(tmp_dir)
tmp_dir = None
return dst
except Exception:
if tmp_dir is not None: # don't leak the temp dir if the copy failed
shutil.rmtree(tmp_dir, ignore_errors = True)
return s

View file

@ -88,7 +88,7 @@
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.55.0",
"vite": "^8.0.1"
"vite": "^8.0.16"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@ -1913,13 +1913,13 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
"@tybys/wasm-util": "^0.10.2"
},
"funding": {
"type": "github",
@ -2027,9 +2027,9 @@
"license": "MIT"
},
"node_modules/@oxc-project/types": {
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
"version": "0.133.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
@ -5583,9 +5583,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
"cpu": [
"arm64"
],
@ -5599,9 +5599,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
"cpu": [
"arm64"
],
@ -5615,9 +5615,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
"cpu": [
"x64"
],
@ -5631,9 +5631,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
"cpu": [
"x64"
],
@ -5647,9 +5647,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
"cpu": [
"arm"
],
@ -5663,12 +5663,15 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5679,12 +5682,15 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -5695,12 +5701,15 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5711,12 +5720,15 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5727,12 +5739,15 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5743,12 +5758,15 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -5759,9 +5777,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
"cpu": [
"arm64"
],
@ -5775,9 +5793,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
"cpu": [
"wasm32"
],
@ -5793,9 +5811,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
"cpu": [
"arm64"
],
@ -5809,9 +5827,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
"cpu": [
"x64"
],
@ -13091,6 +13109,34 @@
"points-on-curve": "0.2.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-selector-parser": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
@ -13104,6 +13150,24 @@
"node": ">=4"
}
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/powershell-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
@ -13977,13 +14041,13 @@
"license": "Unlicense"
},
"node_modules/rolldown": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.127.0",
"@rolldown/pluginutils": "1.0.0-rc.17"
"@oxc-project/types": "=0.133.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@ -13992,27 +14056,27 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
"@rolldown/binding-android-arm64": "1.0.3",
"@rolldown/binding-darwin-arm64": "1.0.3",
"@rolldown/binding-darwin-x64": "1.0.3",
"@rolldown/binding-freebsd-x64": "1.0.3",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
"@rolldown/binding-linux-arm64-musl": "1.0.3",
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
"@rolldown/binding-linux-x64-gnu": "1.0.3",
"@rolldown/binding-linux-x64-musl": "1.0.3",
"@rolldown/binding-openharmony-arm64": "1.0.3",
"@rolldown/binding-wasm32-wasi": "1.0.3",
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
"@rolldown/binding-win32-x64-msvc": "1.0.3"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"license": "MIT"
},
"node_modules/roughjs": {
@ -14275,52 +14339,6 @@
"node": ">=20"
}
},
"node_modules/shadcn/node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/shadcn/node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/shadcn/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
@ -14786,9 +14804,9 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@ -15456,16 +15474,16 @@
}
},
"node_modules/vite": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
"version": "8.0.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.17",
"tinyglobby": "^0.2.16"
"postcss": "^8.5.15",
"rolldown": "1.0.3",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
@ -15481,7 +15499,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@ -15546,52 +15564,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/vite/node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/vite/node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",

View file

@ -107,7 +107,7 @@
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.55.0",
"vite": "^8.0.1"
"vite": "^8.0.16"
},
"allowScripts": {
"@biomejs/biome@1.9.4": true,

View file

@ -45,8 +45,11 @@ import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler";
import { cn } from "@/lib/utils";
import { useWebUpdateCheck } from "@/hooks/use-web-update-check";
import {
Archive03Icon,
ArrowRight02Icon,
BadgeInfoIcon,
ChefHatIcon,
CursorInfo02Icon,
DashboardCircleIcon,
@ -254,6 +257,19 @@ function NavItem({
);
}
// TEMP DEV override: preview the update card on installs with no real update
// (e.g. an editable checkout). In the browser console run
// `localStorage.setItem("unsloth_force_update_card", "1")` and reload. Remove
// before merge.
function devForceUpdateCard(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem("unsloth_force_update_card") === "1";
} catch {
return false;
}
}
export function AppSidebar() {
const t = useT();
const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle();
@ -266,6 +282,16 @@ export function AppSidebar() {
const { togglePinned, isMobile, setOpenMobile } = useSidebar();
const navigate = useNavigate();
// Web update detection: `webUpdate` is non-null only when the installed
// (PyPI) version is behind the latest release, so the card is hidden by
// default. `forceUpdateCard` is a TEMP dev override to preview it on installs
// with no real update (e.g. an editable checkout); remove before merge.
const { status: webUpdate } = useWebUpdateCheck();
const [forceUpdateCard] = useState(devForceUpdateCard);
const showUpdateCard = Boolean(webUpdate) || forceUpdateCard;
const updateVersion =
webUpdate?.latestVersion ?? (forceUpdateCard ? "0.0.0" : null);
// Auto-close mobile Sheet after navigation
const closeMobileIfOpen = () => {
if (isMobile) setOpenMobile(false);
@ -429,6 +455,7 @@ export function AppSidebar() {
chatOpen,
trainOpen,
runsOpen,
pinnedOpen,
isStudioRoute,
]);
@ -1078,6 +1105,16 @@ export function AppSidebar() {
<SidebarContent
ref={scrollRef}
onScroll={(e) => syncScrollState(e.currentTarget)}
// Collapsible groups animate their height; re-measure the fade once the
// open/close animation settles, not on the (still-animating) state flip.
onAnimationEnd={(e) => {
if (
e.animationName === "collapsible-down" ||
e.animationName === "collapsible-up"
) {
syncScrollState(e.currentTarget);
}
}}
className={cn(
// pb-2 keeps the last row's rounded highlight clear of the
// overflow clip edge so its bottom corners aren't shaved off.
@ -1361,18 +1398,75 @@ export function AppSidebar() {
)}
</SidebarContent>
<SidebarFooter className="relative group-data-[collapsible=icon]:px-0">
<SidebarFooter
className={cn(
"relative pb-3 group-data-[collapsible=icon]:px-0",
// Tighter top with the update card so the fade hugs it; fuller top
// for the profile on its own.
showUpdateCard ? "pt-1.5" : "pt-2.5",
)}
>
{/* Fade above the profile box, shown only when there's more list below
the fold; at the bottom (or short lists) it fades so the last row
shows fully (Gemini-style). right-2 keeps it clear of the 8px scrollbar gutter. */}
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute left-0 right-2 bottom-full h-10 bg-gradient-to-t from-[var(--sidebar)] to-[rgb(from_var(--sidebar)_r_g_b/0)] transition-opacity duration-200",
"pointer-events-none absolute left-0 right-2 bottom-full bg-gradient-to-t from-[var(--sidebar)] to-[rgb(from_var(--sidebar)_r_g_b/0)] transition-opacity duration-200",
// Shorter fade when the update card sits above the profile so the
// list reads closer to it.
showUpdateCard ? "h-3" : "h-10",
canScrollDown ? "opacity-100" : "opacity-0",
)}
/>
<SidebarMenu>
<SidebarMenu className="gap-3 group-data-[collapsible=icon]:gap-2.5">
{/* Update affordance — shows only when a newer version is available. */}
{showUpdateCard && (
<SidebarMenuItem>
<button
type="button"
aria-label={t("shell.updateAvailable")}
onClick={() => {
useSettingsDialogStore
.getState()
.openDialog("about", { scrollTarget: "about-updates" });
closeMobileIfOpen();
}}
className="flex h-[44px] w-full items-center gap-[9px] rounded-[14px] border border-border/60 bg-transparent px-2 py-[3px] text-left transition-colors hover:bg-nav-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-data-[collapsible=icon]:mx-auto group-data-[collapsible=icon]:h-[34px] group-data-[collapsible=icon]:w-[34px] group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:rounded-full group-data-[collapsible=icon]:p-0"
>
<span
aria-hidden="true"
className="flex size-[32px] shrink-0 items-center justify-center group-data-[collapsible=icon]:size-full"
>
<HugeiconsIcon
icon={BadgeInfoIcon}
strokeWidth={1.75}
className="size-[21px] text-nav-fg"
/>
</span>
<div className="flex min-w-0 flex-col gap-px leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-heading text-[13.5px] font-semibold text-nav-fg">
{t("shell.updateAvailable")}
</span>
{updateVersion && (
<span className="truncate text-[11.5px] text-muted-foreground">
v{updateVersion}
</span>
)}
</div>
<span
aria-hidden="true"
className="ml-auto flex size-[32px] shrink-0 items-center justify-center text-muted-foreground group-data-[collapsible=icon]:hidden"
>
<HugeiconsIcon
icon={ArrowRight02Icon}
className="size-[17px]"
strokeWidth={1.75}
/>
</span>
</button>
</SidebarMenuItem>
)}
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -1394,11 +1488,16 @@ export function AppSidebar() {
<span className="truncate text-[11.5px] tracking-nav text-muted-foreground">Unsloth</span>
</div>
{/* settings cog (replaces the up/down chevron) */}
<HugeiconsIcon
icon={Settings02Icon}
strokeWidth={1.5}
className="ml-auto !size-[18px] text-muted-foreground group-data-[collapsible=icon]:hidden"
/>
<span
aria-hidden="true"
className="ml-auto flex size-[32px] shrink-0 items-center justify-center text-muted-foreground group-data-[collapsible=icon]:hidden"
>
<HugeiconsIcon
icon={Settings02Icon}
strokeWidth={1.5}
className="!size-[18px]"
/>
</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent

View file

@ -570,6 +570,52 @@ function ModelRow({
// ── GGUF Variant Expander ────────────────────────────────────
function isValidGgufVariant(variant: unknown): variant is GgufVariantDetail {
if (!variant || typeof variant !== "object") return false;
const candidate = variant as Partial<GgufVariantDetail>;
return (
typeof candidate.filename === "string" &&
candidate.filename.length > 0 &&
typeof candidate.quant === "string" &&
candidate.quant.length > 0 &&
typeof candidate.size_bytes === "number" &&
Number.isFinite(candidate.size_bytes) &&
candidate.size_bytes >= 0 &&
(candidate.downloaded === undefined ||
typeof candidate.downloaded === "boolean")
);
}
function normalizeGgufVariantsResponse(res: {
variants?: unknown;
default_variant?: unknown;
has_vision?: unknown;
context_length?: unknown;
} | null | undefined): {
variants: GgufVariantDetail[];
defaultVariant: string | null;
hasVision: boolean;
contextLength: number | null;
} {
const contextLength = res?.context_length;
return {
variants: (Array.isArray(res?.variants) ? res.variants : []).filter(
isValidGgufVariant,
),
defaultVariant:
typeof res?.default_variant === "string" && res.default_variant.length > 0
? res.default_variant
: null,
hasVision: res?.has_vision === true,
contextLength:
typeof contextLength === "number" &&
Number.isFinite(contextLength) &&
contextLength >= 0
? contextLength
: null,
};
}
function GgufVariantExpander({
repoId,
onSelect,
@ -622,11 +668,12 @@ function GgufVariantExpander({
listGgufVariants(repoId)
.then((res) => {
if (canceled) return;
setVariants(res.variants);
setDefaultVariant(res.default_variant);
setHasVision(res.has_vision);
onHasVision?.(res.has_vision);
setNativeContext(res.context_length ?? null);
const normalized = normalizeGgufVariantsResponse(res);
setVariants(normalized.variants);
setDefaultVariant(normalized.defaultVariant);
setHasVision(normalized.hasVision);
onHasVision?.(normalized.hasVision);
setNativeContext(normalized.contextLength);
})
.catch((err) => {
if (canceled) return;
@ -694,19 +741,25 @@ function GgufVariantExpander({
// If the recommended variant is OOM, pick the largest fitting one;
// if all are OOM, recommend the smallest.
const effectiveRecommended = useMemo(() => {
if (!variants || totalBudgetGb <= 0) return defaultVariant;
if (!variants || variants.length === 0 || totalBudgetGb <= 0) {
return defaultVariant;
}
const defaultV = variants.find((v) => v.quant === defaultVariant);
if (defaultV && getGgufFit(defaultV.size_bytes) !== "oom")
return defaultVariant;
// Largest non-OOM variant (best quality that fits)
const fitting = variants.filter((v) => getGgufFit(v.size_bytes) !== "oom");
const fitting = variants.filter(
(v) => getGgufFit(v.size_bytes) !== "oom",
);
if (fitting.length > 0) {
fitting.sort((a, b) => b.size_bytes - a.size_bytes);
return fitting[0].quant;
}
// All OOM -- recommend smallest (most likely to partially run)
const sorted = [...variants].sort((a, b) => a.size_bytes - b.size_bytes);
return sorted[0].quant;
const sorted = [...variants].sort(
(a, b) => a.size_bytes - b.size_bytes,
);
return sorted[0]?.quant ?? defaultVariant;
}, [variants, defaultVariant, totalBudgetGb, getGgufFit]);
const sortedVariants = useMemo(() => {
@ -2336,7 +2389,7 @@ export function HubModelPicker({
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search models"
placeholder="Search Unsloth models"
data-model-picker-search-input={true}
className="field-soft h-9 border-0 pl-8 pr-8"
/>
@ -2345,15 +2398,20 @@ export function HubModelPicker({
)}
</div>
{onBrowseHub ? (
<button
type="button"
onClick={onBrowseHub}
aria-label="Search more models on the Hub"
className="hub-tab-toggle-pill flex h-9 w-[110px] shrink-0 items-center justify-center gap-[5px] rounded-full border-0 text-xs text-foreground transition-colors"
>
<HugeiconsIcon icon={DashboardCircleIcon} className="size-4" />
Search Hub
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onBrowseHub}
aria-label="Search more models on the Hub"
className="hub-tab-toggle-pill flex h-9 w-[110px] shrink-0 items-center justify-center gap-[5px] rounded-full border-0 text-xs text-foreground transition-colors"
>
<HugeiconsIcon icon={DashboardCircleIcon} className="size-4" />
Search Hub
</button>
</TooltipTrigger>
<TooltipContent>Search all models</TooltipContent>
</Tooltip>
) : null}
</div>
@ -2386,7 +2444,7 @@ export function HubModelPicker({
// Height tracks the content up to the cap, so short lists do not
// leave white space. scroll-py + symmetric px keep the focus ring off
// the overflow clip edges during keyboard nav.
"model-list-scroll max-h-[21rem] overflow-y-auto scroll-py-1.5 px-0.5 mr-1",
"model-list-scroll max-h-[335px] overflow-y-auto scroll-py-1.5 px-0.5 mr-1",
listScrolled && "is-scrolled",
listMoreBelow && "is-bottom-faded",
)}
@ -2896,7 +2954,7 @@ export function HubModelPicker({
}
}}
onArrowDownIntoChildren={
isGgufExpanded(m.id)
isGguf && !isDirectGguf && isGgufExpanded(m.id)
? () => {
const focused =
focusFirstChildOption(optionKey);
@ -2906,7 +2964,7 @@ export function HubModelPicker({
}
vramStatus={null}
/>
{isGgufExpanded(m.id) && (
{isGguf && !isDirectGguf && isGgufExpanded(m.id) && (
<GgufVariantExpander
repoId={m.id}
onDevice={true}
@ -2983,7 +3041,7 @@ export function HubModelPicker({
}
}}
onArrowDownIntoChildren={
!isGgufFile && isGgufExpanded(m.id)
isGguf && !isGgufFile && isGgufExpanded(m.id)
? () => {
const focused =
focusFirstChildOption(optionKey);
@ -2993,7 +3051,7 @@ export function HubModelPicker({
}
vramStatus={null}
/>
{!isGgufFile && isGgufExpanded(m.id) && (
{isGguf && !isGgufFile && isGgufExpanded(m.id) && (
<GgufVariantExpander
repoId={m.id}
onDevice={true}
@ -3064,13 +3122,13 @@ export function HubModelPicker({
}
}}
onArrowDownIntoChildren={
!isGgufFile && isGgufExpanded(m.id)
isGguf && !isGgufFile && isGgufExpanded(m.id)
? () => focusFirstChildOption(optionKey)
: undefined
}
vramStatus={null}
/>
{!isGgufFile && isGgufExpanded(m.id) && (
{isGguf && !isGgufFile && isGgufExpanded(m.id) && (
<GgufVariantExpander
repoId={m.id}
onDevice={true}
@ -3362,7 +3420,7 @@ export function HubModelPicker({
{/* Floating eject pill: overlaid on the list bottom, outside the scroll
so the edge fade never touches it. Only the pill catches clicks. */}
{onEject ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-end pr-3.5 pb-5">
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-end pr-3.5 pb-[19px]">
<button
type="button"
onClick={onEject}

View file

@ -14,6 +14,21 @@ export interface RememberedLoadSettings {
tensorParallel: boolean;
}
// Storage key for a pick's remembered settings. The remembered knobs are
// VRAM-budget driven (context override, KV-cache dtype, tensor-parallel), so the
// right values differ per quant. An HF repo collapses all its GGUF variants into
// one `id`, so fold the variant in to scope settings per quant. Local .gguf
// paths key by their file path (already file-specific); native drag-drop files
// key by display label, so same-named files in different folders share an entry.
export function rememberedLoadSettingsKey(selection: {
id: string;
ggufVariant?: string | null;
}): string {
return selection.ggufVariant
? `${selection.id}::${selection.ggufVariant}`
: selection.id;
}
function readAll(): Record<string, RememberedLoadSettings> {
try {
return JSON.parse(localStorage.getItem(KEY) ?? "{}");
@ -31,24 +46,24 @@ function writeAll(all: Record<string, RememberedLoadSettings>) {
}
export function loadRememberedLoadSettings(
modelId: string,
key: string,
): RememberedLoadSettings | null {
return readAll()[modelId] ?? null;
return readAll()[key] ?? null;
}
export function saveRememberedLoadSettings(
modelId: string,
key: string,
settings: RememberedLoadSettings,
) {
const all = readAll();
all[modelId] = settings;
all[key] = settings;
writeAll(all);
}
export function clearRememberedLoadSettings(modelId: string) {
export function clearRememberedLoadSettings(key: string) {
const all = readAll();
if (modelId in all) {
delete all[modelId];
if (key in all) {
delete all[key];
writeAll(all);
}
}

View file

@ -20,7 +20,8 @@ import {
} from "@assistant-ui/react";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { type VariantProps, cva } from "class-variance-authority";
import { ChevronDownIcon, CopyIcon, LightbulbIcon } from "lucide-react";
import { ChevronDownIcon, CopyIcon } from "lucide-react";
import { BulbIcon } from "@/lib/bulb-icon";
import { Tick02Icon } from "@/lib/tick-icon";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@ -128,7 +129,7 @@ function ReasoningTrigger({
)}
{...props}
>
<LightbulbIcon className="aui-reasoning-trigger-icon size-4 shrink-0" />
<BulbIcon className="aui-reasoning-trigger-icon size-4 shrink-0" />
<span
data-slot="reasoning-trigger-label"
className="aui-reasoning-trigger-label-wrapper relative inline-block leading-none"
@ -393,7 +394,8 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({
<ReasoningTrigger
className="min-w-0 flex-1"
active={isReasoningStreaming}
duration={duration || persistedDuration}
// Prefer server timing when available.
duration={persistedDuration || duration}
/>
<div className="flex w-16 shrink-0 justify-end">
{isOpen && !isReasoningStreaming && (

View file

@ -3043,6 +3043,7 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
type="button"
aria-label="Tools and attachments"
className="unsloth-composer-plus"
data-tour="chat-plus-menu"
>
<PlusIcon className="size-[22px] stroke-[1.75px]" />
</button>

View file

@ -2627,6 +2627,17 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
continue;
}
// Local GGUF sends server-timed reasoning duration. Guard the type
// so a malformed or proxied chunk (string/null/NaN duration) can
// never turn the label into NaN.
const reasoningMs = (
chunk as { _reasoningDurationMs?: number } | null | undefined
)?._reasoningDurationMs;
if (typeof reasoningMs === "number" && Number.isFinite(reasoningMs)) {
reasoningDuration = Math.max(0, Math.round(reasoningMs / 1000));
continue;
}
// Diffusion frame: a transient canvas snapshot. Route it to the transient
// store (the in-bubble renderer reads it) and skip it; it has no assistant
// text, so it never enters the transcript or the counters below.
@ -3175,6 +3186,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
}
const textParts = parseAssistantContent(cumulativeText);
// Fallback when no server-side reasoning_summary arrives.
if (
textParts.some((part) => part.type === "reasoning") &&
!reasoningStartAt
@ -3283,6 +3295,13 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
finalTokPerSec,
);
// Finalize reasoning-only streams.
if (reasoningStartAt && !reasoningDuration) {
reasoningDuration = Math.max(
0,
Math.round((Date.now() - reasoningStartAt) / 1000),
);
}
yield {
content: [
...buildAssistantContent(cumulativeText),

View file

@ -902,6 +902,19 @@ export async function* streamChatCompletions(
separatorIndex = buffer.search(/\r?\n\r?\n/);
continue;
}
// Relay server-side reasoning duration.
if (
parsed &&
typeof parsed === "object" &&
"type" in parsed &&
parsed.type === "reasoning_summary"
) {
yield {
_reasoningDurationMs: (parsed as { duration_ms?: number }).duration_ms,
} as unknown as OpenAIChatChunk;
separatorIndex = buffer.search(/\r?\n\r?\n/);
continue;
}
yield parsed as OpenAIChatChunk;
separatorIndex = buffer.search(/\r?\n\r?\n/);
}

View file

@ -8,6 +8,10 @@ import {
type ModelOption,
ModelSelector,
} from "@/components/assistant-ui/model-selector";
import {
loadRememberedLoadSettings,
rememberedLoadSettingsKey,
} from "@/components/assistant-ui/model-selector/remembered-load-settings";
import { ProjectComposer, Thread } from "@/components/assistant-ui/thread";
import { CopyableErrorChip } from "@/components/ui/copyable-error-chip";
import {
@ -1275,13 +1279,18 @@ export function ChatPage({
selectModelRef.current = selectModel;
}, [refresh, selectModel]);
// Load a cached autoLoad pick once its download finishes. The sheet was never
// opened, so on a load failure just drop the orphaned staged knobs.
// opened, so on a load failure just drop the orphaned staged knobs. The knobs
// were already seeded on stage, so keepSpeculative only when a config was
// saved -- otherwise the standing speculative preference should win.
autoLoadStagedRef.current = (pending) => {
const remembered = loadRememberedLoadSettings(
rememberedLoadSettingsKey(pending),
);
void selectModel({
...pending,
isDownloaded: true,
forceReload: true,
keepSpeculative: false,
keepSpeculative: remembered != null,
throwOnError: true,
}).catch(() => {
const store = useChatRuntimeStore.getState();
@ -1648,10 +1657,21 @@ export function ChatPage({
(!hasGgufSource(selection) && !wantManagerDownload) ||
(store.loadOnSelection && selection.isDownloaded)
) {
// Detach any staged pick first so its edited knobs don't leak into this
// immediate load. Detach (not abandon) keeps its download running.
// Detach any staged pick first so its edited knobs (e.g. a custom
// context length) don't leak into this immediate load -- resolveLoad
// reads customContextLength before checking the target is GGUF. Detach
// (not abandon) keeps its download running.
detachStaged();
await selectModel(selection);
// Load-on-selection skips the sheet, so seed the saved knobs here the
// way the sheet's restore effect would; the switch would otherwise reset
// the remembered speculative choice (keepSpeculative below prevents it).
const remembered = hasGgufSource(selection)
? loadRememberedLoadSettings(rememberedLoadSettingsKey(selection))
: null;
if (remembered) store.applyRememberedLoadSettings(remembered);
await selectModel(
remembered ? { ...selection, keepSpeculative: true } : selection,
);
return;
}
// Loads can't queue behind each other, but a download is independent: if
@ -2506,7 +2526,6 @@ export function ChatPage({
onClick={() => setSettingsOpen(true)}
className="flex h-[34px] w-[34px] translate-x-[2px] cursor-pointer items-center justify-center rounded-full text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Open run settings"
data-tour="chat-settings"
>
<HugeiconsIcon
icon={LayoutAlignRightIcon}

View file

@ -21,6 +21,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import {
clearRememberedLoadSettings,
loadRememberedLoadSettings,
rememberedLoadSettingsKey,
saveRememberedLoadSettings,
} from "@/components/assistant-ui/model-selector/remembered-load-settings";
import {
@ -579,6 +580,9 @@ export function ChatSettingsPanel({
);
const kvCacheDtype = useChatRuntimeStore((s) => s.kvCacheDtype);
const setKvCacheDtype = useChatRuntimeStore((s) => s.setKvCacheDtype);
const applyRememberedLoadSettings = useChatRuntimeStore(
(s) => s.applyRememberedLoadSettings,
);
const loadedKvCacheDtype = useChatRuntimeStore((s) => s.loadedKvCacheDtype);
const tensorParallel = useChatRuntimeStore((s) => s.tensorParallel);
const setTensorParallel = useChatRuntimeStore((s) => s.setTensorParallel);
@ -613,25 +617,16 @@ export function ChatSettingsPanel({
// the saved per-model settings on stage, so the sheet opens with what was used
// last time; the tick reflects whether a saved entry exists.
const [remember, setRemember] = useState(false);
const pendingId = pendingSelection?.id ?? null;
// Keyed per quant: a different variant of the same repo has its own settings.
const pendingKey = pendingSelection
? rememberedLoadSettingsKey(pendingSelection)
: null;
useEffect(() => {
if (!pendingId) return;
const saved = loadRememberedLoadSettings(pendingId);
if (!pendingKey) return;
const saved = loadRememberedLoadSettings(pendingKey);
setRemember(saved != null);
if (!saved) return;
setCustomContextLength(saved.contextLength);
setKvCacheDtype(saved.kvCacheDtype);
setSpeculativeType(saved.speculativeType ?? "auto");
setSpecDraftNMax(saved.specDraftNMax);
setTensorParallel(saved.tensorParallel);
}, [
pendingId,
setCustomContextLength,
setKvCacheDtype,
setSpeculativeType,
setSpecDraftNMax,
setTensorParallel,
]);
if (saved) applyRememberedLoadSettings(saved);
}, [pendingKey, applyRememberedLoadSettings]);
// While staging, the sheet reflects the STAGED model, so its header context
// takes precedence over the loaded model's (which may differ or be larger).
const baseContext = pendingIsGguf ? stagedContextLength : ggufContextLength;
@ -1213,9 +1208,11 @@ export function ChatSettingsPanel({
type="button"
onClick={() => {
// Persist (or clear) this model's load knobs before loading.
// Save the explicit context override only (null = auto), so
// restoring never forces the native context into an OOM.
const pid = pendingSelection?.id;
// Context is stored as the override (null = auto), never the
// resolved native value, so restoring can't force an OOM.
const pid = pendingSelection
? rememberedLoadSettingsKey(pendingSelection)
: null;
if (pid) {
if (remember) {
saveRememberedLoadSettings(pid, {
@ -1775,7 +1772,9 @@ export function ChatSettingsPanel({
<SheetTitle>Run settings</SheetTitle>
<SheetDescription>Chat inference settings</SheetDescription>
</SheetHeader>
<div className="flex h-full flex-col">{settingsContent}</div>
<div data-tour="chat-settings" className="flex h-full flex-col">
{settingsContent}
</div>
</SheetContent>
</Sheet>
);
@ -1783,6 +1782,7 @@ export function ChatSettingsPanel({
return (
<aside
data-tour="chat-settings"
className={`relative z-50 shrink-0 h-full overflow-hidden bg-panel-surface text-panel-surface-fg font-heading ${open ? "w-[17rem] border-l border-sidebar-border" : "w-0"}`}
>
<div className="h-full w-full">{settingsContent}</div>

View file

@ -60,6 +60,11 @@ type McpPreset = {
// Keyless remote MCP presets (rate-limited free tiers, no API key).
// Hugging Face runs anonymously; add a token via "Manage MCP servers".
const MCP_PRESETS: readonly McpPreset[] = [
{
id: "unsloth-docs",
displayName: "Unsloth Docs",
url: "https://unsloth.ai/docs/~gitbook/mcp",
},
{
id: "context7",
displayName: "Context7",

View file

@ -12,7 +12,9 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
@ -300,33 +302,53 @@ export function ProjectsPage() {
<DropdownMenuSub>
<DropdownMenuSubTrigger>Export All Projects</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52">
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`ap-m-${fmt}`} onSelect={() => void handleBulkProjectExport("projects", fmt, true)}>
{label} (combined)
</DropdownMenuItem>
))}
<DropdownMenuGroup>
<DropdownMenuLabel className="pb-1 pt-2 text-[11px] font-medium">
Combined
</DropdownMenuLabel>
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`ap-m-${fmt}`} onSelect={() => void handleBulkProjectExport("projects", fmt, true)}>
{label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`ap-s-${fmt}`} onSelect={() => void handleBulkProjectExport("projects", fmt, false)}>
{label} (per chat)
</DropdownMenuItem>
))}
<DropdownMenuGroup>
<DropdownMenuLabel className="pb-1 pt-2 text-[11px] font-medium">
Per chat
</DropdownMenuLabel>
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`ap-s-${fmt}`} onSelect={() => void handleBulkProjectExport("projects", fmt, false)}>
{label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Export Projects + Recents</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52">
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`all-m-${fmt}`} onSelect={() => void handleBulkProjectExport("all", fmt, true)}>
{label} (combined)
</DropdownMenuItem>
))}
<DropdownMenuGroup>
<DropdownMenuLabel className="pb-1 pt-2 text-[11px] font-medium">
Combined
</DropdownMenuLabel>
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`all-m-${fmt}`} onSelect={() => void handleBulkProjectExport("all", fmt, true)}>
{label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`all-s-${fmt}`} onSelect={() => void handleBulkProjectExport("all", fmt, false)}>
{label} (per chat)
</DropdownMenuItem>
))}
<DropdownMenuGroup>
<DropdownMenuLabel className="pb-1 pt-2 text-[11px] font-medium">
Per chat
</DropdownMenuLabel>
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`all-s-${fmt}`} onSelect={() => void handleBulkProjectExport("all", fmt, false)}>
{label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>

View file

@ -7,6 +7,7 @@ import {
thinkToggleAriaLabel,
} from "@/components/assistant-ui/think-aria-label";
import { Button } from "@/components/ui/button";
import { BulbIcon } from "@/lib/bulb-icon";
import { Tick02Icon } from "@/lib/tick-icon";
import { cn } from "@/lib/utils";
import {
@ -155,20 +156,6 @@ const MicIcon: FC<{ className?: string }> = ({ className }) => (
</svg>
);
const BulbIcon: FC<{ className?: string }> = ({ className }) => (
<svg
className={className}
viewBox="-10.24 -10.24 1044.48 1044.48"
fill="currentColor"
stroke="currentColor"
strokeWidth={16.384}
xmlns="http://www.w3.org/2000/svg"
aria-hidden={true}
>
<path d="M511.984 0c-198.032 0-353.12 161.104-353.12 359.136 0 149.2 73.28 220.256 131.185 272.128 37.28 33.424 62.368 53.552 62.368 78.352v54.255c0 1.392.193 2.752.368 4.128h-.72v92.624c.016 97.712 63.2 163.376 161.072 163.376 94.464 0 158.944-65.664 158.944-163.376V768h-.928c.176-1.376.416-2.736.416-4.128v-54.255c0-37.76 28.032-60.592 70.528-97.696 57.504-50.208 123.023-112.688 123.023-252.784C865.136 161.104 710.016 0 511.983 0zm-1.215 960c-59.904 0-94.689-37.152-94.689-99.376l-.463-42.672C438.64 825.824 470 832 512 832c41.424 0 72.848-6.624 96.08-14.768v43.392c0 63.152-35.247 99.376-97.312 99.376zm189.248-396.288c-43.472 37.968-92.433 77.216-92.433 145.904v40.432c-15.183 8.48-43.183 18.56-96.127 18.56-55.569 0-81.92-9.856-95.024-17.473V709.6c0-54.608-42.688-89.297-83.68-126.017-54.32-48.672-109.873-103.84-109.873-224.464-.015-162.72 126.385-295.12 289.104-295.12 162.752 0 289.152 132.4 289.152 295.137 0 111.024-48.463 158.576-101.12 204.576z" />
</svg>
);
function isNativeComposing(event: Event) {
return "isComposing" in event && (event as InputEvent).isComposing === true;
}
@ -625,6 +612,12 @@ export function SharedComposer({
const isEffort =
effectiveReasoningStyle === "reasoning_effort" ||
effectiveReasoningStyle === "enable_thinking_effort";
// GLM-5.2's effort menu (Off, high, max) has short rows, so it can sit a
// touch skinnier. Skip the narrower floor when a Preserve thinking row is
// present, since that longer label needs the wider width to stay one line.
const narrowEffortMenu =
effectiveReasoningStyle === "enable_thinking_effort" &&
!supportsPreserveThinking;
const thinkingActiveLook = isEffort
? reasoningLockedOn || (effectiveReasoningVisualEnabled && !reasoningDisabled)
: reasoningLockedOn || (effectiveReasoningEnabled && !reasoningDisabled);
@ -1798,7 +1791,10 @@ export function SharedComposer({
<DropdownMenuContent
side="top"
align="end"
className="unsloth-plus-menu min-w-44"
className={cn(
"unsloth-plus-menu",
narrowEffortMenu ? "min-w-40" : "min-w-44",
)}
>
{isEffort ? (
<>

View file

@ -1,6 +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 type { RememberedLoadSettings } from "@/components/assistant-ui/model-selector/remembered-load-settings";
import { cancelStagedModelDownload } from "@/features/hub";
import { toast } from "@/lib/toast";
import { create } from "zustand";
@ -770,6 +771,10 @@ type ChatRuntimeStore = {
* start each deferred-staging session clean so one staged pick's settings
* don't leak onto the next. */
resetModelSettingsToLoaded: () => void;
/** Seed the editable load knobs from a model's remembered settings. Shared by
* the settings sheet's restore effect and the "Load on selection" paths,
* which skip the sheet but must still honor a saved config. */
applyRememberedLoadSettings: (settings: RememberedLoadSettings) => void;
setTensorParallel: (value: boolean) => void;
setLoadOnSelection: (value: boolean) => void;
setExpandQuantizations: (value: boolean) => void;
@ -1528,6 +1533,16 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
setSpecDraftNMax: (specDraftNMax) => set({ specDraftNMax }),
setTensorParallel: (tensorParallel) => set({ tensorParallel }),
resetModelSettingsToLoaded: () => set((s) => loadedBaselineSettings(s)),
applyRememberedLoadSettings: (settings) =>
// Coalesce every field: a blob persisted by an older/newer build can omit
// keys, and a raw spread would push `undefined` into fields typed non-null.
set({
customContextLength: settings.contextLength ?? null,
kvCacheDtype: settings.kvCacheDtype ?? null,
speculativeType: settings.speculativeType ?? "auto",
specDraftNMax: settings.specDraftNMax ?? null,
tensorParallel: settings.tensorParallel ?? false,
}),
setLoadOnSelection: (loadOnSelection) => {
saveBool(CHAT_LOAD_ON_SELECTION_KEY, loadOnSelection);
set({ loadOnSelection });

View file

@ -27,20 +27,21 @@ export function buildChatTourSteps({
title: "Pick a model",
body: (
<>
This selects whats loaded for inference. Hub = base models. Fine-tuned
= trained Unsloth outputs, including LoRA adapters and full finetunes.
Selects whats loaded for inference. Recommended is Unsloths curated
base models; On Device is your downloads and fine-tuned outputs (LoRA
adapters and full finetunes).
</>
),
},
{
id: "model-tabs",
target: "chat-model-selector-popover",
title: "Two tabs",
title: "Find a model",
body: (
<>
Hub: search Hugging Face models. Fine-tuned: local Unsloth outputs youve
trained or exported. If results look off, compare base vs fine-tuned
outputs to see what changed.
Search Unsloths models, or hit Search Hub for all of Hugging Face.
Switch Recommended and On Device, filter by format, and sort by
trending or recent. An OOM tag means it wont fit in your VRAM.
</>
),
onEnter: openModelSelector,
@ -59,6 +60,18 @@ export function buildChatTourSteps({
onEnter: openSettings,
onExit: closeSettings,
},
{
id: "plus-menu",
target: "chat-plus-menu",
title: "The + menu",
body: (
<>
Everything else lives here: attach photos and files, reuse saved
prompts, toggle tools and MCP, start a side-by-side compare, and
export the chat.
</>
),
},
];
if (canCompare) {

View file

@ -252,7 +252,7 @@ export const ModelsToolbar = memo(function ModelsToolbar({
? `Search on-device ${isDataset ? "datasets" : "models"}`
: isDataset
? "Search datasets"
: "Search models"
: "Search all models"
}
className={cn(
"field-soft h-9 rounded-full !border-0 pl-10 text-[13px] placeholder:text-muted-foreground/80 focus-visible:!ring-0",

View file

@ -19,24 +19,6 @@ const SIZES: Record<AvatarSize, string> = {
lg: "size-12 rounded-[15px] text-[16px]",
};
// Unsloth's own uploads (no upstream provider match) show the bundled Unsloth
// brand avatar instead of a colored-initial tile, so they read as Unsloth even
// in virtualized rows that never fetch the HF profile picture.
const UNSLOTH_OWNER_LOGO: ProviderLogo = {
id: "unsloth",
name: "Unsloth",
logoPath: "/circle-logo-small.png",
treatment: "original",
background: "transparent",
fit: "cover",
// Used directly for the unsloth owner, never via prefix matching.
prefixes: [],
};
function isUnslothOwner(owner: string): boolean {
return owner.trim().toLowerCase() === "unsloth";
}
const AVATAR_IMAGE_RETRY_BASE_MS = 60_000;
const AVATAR_IMAGE_RETRY_MAX_MS = 30 * 60_000;
@ -86,15 +68,6 @@ export function OwnerAvatar({
/>
);
}
if (isUnslothOwner(owner)) {
return (
<ProviderLogoTile
provider={UNSLOTH_OWNER_LOGO}
size={size}
className={className}
/>
);
}
return (
<DefaultAvatar
owner={owner}
@ -114,9 +87,6 @@ export function useAvatarImageUrl(
if (provider) {
return provider.treatment === "original" ? provider.logoPath : null;
}
if (isUnslothOwner(owner)) {
return UNSLOTH_OWNER_LOGO.logoPath;
}
return remoteUrl;
}

View file

@ -28,8 +28,8 @@ export function OwnerScopeToggle({
onValueChange={onChange}
ariaLabel="Publisher scope"
align="end"
// Extra gap so the chevron sits a touch further from the label.
className="h-8 gap-1.5 text-[11.5px]"
// Extra gap before the chevron; min-width keeps the pill readable.
className="h-8 min-w-[96px] gap-1.5 text-[11.5px]"
/>
);
}

View file

@ -1,6 +1,10 @@
// 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 {
loadRememberedLoadSettings,
rememberedLoadSettingsKey,
} from "@/components/assistant-ui/model-selector/remembered-load-settings";
import { useHubInventory } from "@/features/hub/inventory";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useGpuInfo } from "@/hooks/use-gpu-info";
@ -14,7 +18,10 @@ import { useHubInfiniteScroll } from "@/features/hub/hooks/use-hub-infinite-scro
import { ggufVariantsMatch, modelIdsMatch } from "@/features/hub/lib/model-identity";
import { cn } from "@/lib/utils";
import { usePlatformStore } from "@/config/env";
import { useHfTokenStore } from "@/features/hub/stores/hf-token-store";
import {
hfApiToken,
useHfTokenStore,
} from "@/features/hub/stores/hf-token-store";
import {
getInferenceStatus,
isExternalModelId,
@ -90,18 +97,19 @@ const ALL_MODELS_VIEW_STORAGE_KEY = "unsloth.hub.allModelsView";
const INVENTORY_SORT_STORAGE_KEY = "unsloth.hub.inventorySort";
const OWNER_SCOPE_STORAGE_KEY = "unsloth.hub.ownerScope";
/** Discover browsing scope: only the unsloth org (default) or the whole Hub. */
/** Discover browsing scope: the whole Hub (default) or only the unsloth org. */
export type OwnerScope = "unsloth" | "all";
function readOwnerScopePreference(): OwnerScope {
if (typeof window === "undefined") {
return "unsloth";
return "all";
}
try {
const value = window.localStorage.getItem(OWNER_SCOPE_STORAGE_KEY);
return value === "all" ? "all" : "unsloth";
// Default to the whole Hub; only honor an explicit "unsloth" preference.
return value === "unsloth" ? "unsloth" : "all";
} catch {
return "unsloth";
return "all";
}
}
@ -550,6 +558,7 @@ export function ModelsPage() {
const deferredDebouncedQuery = useDeferredValue(debouncedQuery);
const hfToken = useHfTokenStore((s) => s.token);
const debouncedHfToken = useDebouncedValue(hfToken, 500);
const apiHfToken = hfApiToken(debouncedHfToken);
const deferredFormatFilter = useDeferredValue(formatFilter);
const deferredCapabilityFilter = useDeferredValue(capabilityFilter);
@ -604,7 +613,7 @@ export function ModelsPage() {
handleRetrySearch,
} = useDiscoverSearch({
debouncedQuery,
accessToken: debouncedHfToken || undefined,
accessToken: apiHfToken,
isDiscoverTab,
isDatasetMode,
sortBy: effectiveSort,
@ -618,7 +627,7 @@ export function ModelsPage() {
channelId: isChannelListMode ? activeChannelId : null,
results,
isLoading,
accessToken: debouncedHfToken || undefined,
accessToken: apiHfToken,
});
const {
@ -701,7 +710,7 @@ export function ModelsPage() {
const listRows = filteredDiscoverRows;
const hubFeed = useHubFeed({
accessToken: debouncedHfToken || undefined,
accessToken: apiHfToken,
online,
enabled: isFeedMode,
deviceType,
@ -907,7 +916,7 @@ export function ModelsPage() {
filteredCachedRows,
filteredLocalRows,
results: selectionResults,
accessToken: debouncedHfToken || undefined,
accessToken: apiHfToken,
online,
});
@ -1083,11 +1092,32 @@ export function ModelsPage() {
openNewChat();
return;
}
// Detach any leftover staged pick first so its edited knobs (e.g. a custom
// context length) don't leak into this load -- mirrors the chat page's
// detachStaged(); keepDownload keeps any staged download running.
useChatRuntimeStore.getState().abandonStagedModel({ keepDownload: true });
// Load-on-selection skips the chat sheet, so seed this GGUF pick's saved
// load knobs here the way the sheet's restore effect would; otherwise the
// remembered config is silently ignored on the Hub run path. keepSpeculative
// then honors the restored speculative choice across the switch.
const remembered =
opts.ggufVariant != null || selectedModel.isGguf
? loadRememberedLoadSettings(
rememberedLoadSettingsKey({
id: runId,
ggufVariant: opts.ggufVariant,
}),
)
: null;
if (remembered) {
useChatRuntimeStore.getState().applyRememberedLoadSettings(remembered);
}
void selectModel({
id: runId,
ggufVariant: opts.ggufVariant,
isDownloaded,
expectedBytes: opts.expectedBytes,
keepSpeculative: remembered != null,
throwOnError: true,
})
.then(() => {

View file

@ -116,3 +116,11 @@ export const useHfTokenStore = create<HfTokenStore>((set) => {
export function getHfToken(): string {
return useHfTokenStore.getState().token;
}
// HF's JS client throws on a non-empty token that isn't `hf_...` instead of
// browsing anonymously, so treat anything malformed as no token.
export function hfApiToken(
token: string | undefined | null,
): string | undefined {
return token && token.startsWith("hf_") ? token : undefined;
}

View file

@ -12,9 +12,16 @@ export type SettingsTab =
| "api-keys"
| "about";
export type SettingsScrollTarget = "about-updates";
interface OpenDialogOptions {
scrollTarget?: SettingsScrollTarget;
}
interface SettingsDialogState {
open: boolean;
activeTab: SettingsTab;
scrollTarget: SettingsScrollTarget | null;
// Element focused when openDialog() ran. Radix's FocusScope normally tracks
// this, but the rAF-scheduled focus() in settings-dialog.tsx races its
// previous-focus capture, leaving focus on <body> after close. We restore
@ -23,9 +30,10 @@ interface SettingsDialogState {
// Set when something asks to jump straight to the archived chats list (the
// archive toast). ChatTab consumes it to open the dialog, then clears it.
archivedChatsRequested: boolean;
openDialog: (tab?: SettingsTab) => void;
openDialog: (tab?: SettingsTab, options?: OpenDialogOptions) => void;
openArchivedChats: () => void;
consumeArchivedChatsRequest: () => void;
consumeScrollTarget: (target: SettingsScrollTarget) => void;
closeDialog: () => void;
setActiveTab: (tab: SettingsTab) => void;
}
@ -65,32 +73,39 @@ function loadInitialTab(): SettingsTab {
export const useSettingsDialogStore = create<SettingsDialogState>((set) => ({
open: false,
activeTab: loadInitialTab(),
scrollTarget: null,
opener: null,
archivedChatsRequested: false,
openDialog: (tab) =>
openDialog: (tab, options) =>
set((state) => ({
open: true,
activeTab: tab ?? state.activeTab,
scrollTarget: options?.scrollTarget ?? null,
opener: captureOpener(),
})),
openArchivedChats: () =>
set({
open: true,
activeTab: "chat",
scrollTarget: null,
archivedChatsRequested: true,
opener: captureOpener(),
}),
consumeArchivedChatsRequest: () => set({ archivedChatsRequested: false }),
consumeScrollTarget: (target) =>
set((state) => ({
scrollTarget: state.scrollTarget === target ? null : state.scrollTarget,
})),
// Do NOT clear `opener` here. onCloseAutoFocus runs on the next render
// pass after `open: false` lands, so the opener must still be readable
// from the store at that point. The next openDialog() overwrites it.
closeDialog: () => set({ open: false }),
closeDialog: () => set({ open: false, scrollTarget: null }),
setActiveTab: (tab) => {
try {
window.localStorage.setItem(ACTIVE_TAB_KEY, tab);
} catch {
// ignore storage failures
}
set({ activeTab: tab });
set({ activeTab: tab, scrollTarget: null });
},
}));

View file

@ -17,7 +17,7 @@ import {
NewReleasesIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
import { StudioVersionSection } from "../components/studio-version-section";
@ -25,6 +25,7 @@ import {
type UpdateInstallSource,
UpdateStudioInstructions,
} from "../components/update-studio-instructions";
import { useSettingsDialogStore } from "../stores/settings-dialog-store";
type ApiObject = Record<string, unknown>;
@ -76,6 +77,11 @@ export function AboutTab() {
const deviceType = usePlatformStore((s) => s.deviceType);
const defaultShell = deviceType === "windows" ? "windows" : "unix";
const hw = useHardwareInfo();
const updateSectionRef = useRef<HTMLDivElement | null>(null);
const scrollTarget = useSettingsDialogStore((s) => s.scrollTarget);
const consumeScrollTarget = useSettingsDialogStore(
(s) => s.consumeScrollTarget,
);
const [shutdownOpen, setShutdownOpen] = useState(false);
const [installSource, setInstallSource] = useState<
UpdateInstallSource | "loading"
@ -95,6 +101,20 @@ export function AboutTab() {
};
}, []);
useEffect(() => {
if (scrollTarget !== "about-updates") {
return;
}
const frame = window.requestAnimationFrame(() => {
updateSectionRef.current?.scrollIntoView({
block: "start",
behavior: "smooth",
});
consumeScrollTarget("about-updates");
});
return () => window.cancelAnimationFrame(frame);
}, [consumeScrollTarget, scrollTarget]);
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
@ -110,15 +130,17 @@ export function AboutTab() {
Unsloth/Package rows; the prop keeps it About-only (General passes none). */}
<StudioVersionSection llamaCppVersion={hw.llamaCpp} />
<SettingsSection title={t("settings.about.updates")}>
<div className="py-2">
<UpdateStudioInstructions
defaultShell={defaultShell}
installSource={isTauri ? null : installSource}
showTitle={false}
/>
</div>
</SettingsSection>
<div ref={updateSectionRef} className="scroll-mt-5">
<SettingsSection title={t("settings.about.updates")}>
<div className="py-2">
<UpdateStudioInstructions
defaultShell={defaultShell}
installSource={isTauri ? null : installSource}
showTitle={false}
/>
</div>
</SettingsSection>
</div>
{hw.gpus.length > 0 || hw.cuda || hw.rocm ? (
<SettingsSection title={t("settings.about.hardware")}>

View file

@ -25,8 +25,9 @@ import {
useShowLlamaUpdateBanner,
} from "@/hooks/use-llama-update-pref";
import { LOCALE_STORAGE_KEY, useT } from "@/i18n";
import { cn } from "@/lib/utils";
import { useNavigate, useRouterState } from "@tanstack/react-router";
import { Eye, EyeOff } from "lucide-react";
import { Check, Eye, EyeOff } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
type HelperPrecacheSettings,
@ -171,6 +172,12 @@ export function GeneralTab() {
if (trimmed !== hfToken) setHfToken(trimmed);
};
// Show an "accepted" tick once a non-empty token has been committed to the
// store and the field still matches it (i.e. not mid-edit). Gives the user
// feedback that a pasted token was saved.
const tokenSaved =
draftToken.trim().length > 0 && draftToken.trim() === (hfToken ?? "");
useEffect(() => {
let cancelled = false;
void loadUploadLimitSettings()
@ -319,8 +326,22 @@ export function GeneralTab() {
value={draftToken}
onChange={(e) => setDraftToken(e.target.value)}
onBlur={commitToken}
className="h-8 w-full pr-8 font-mono text-xs"
className={cn(
"h-8 w-full font-mono text-xs",
tokenSaved ? "pr-14" : "pr-8",
)}
/>
{tokenSaved ? (
// Decorative: pointer-events-none lets clicks reach the input
// underneath so the field still focuses anywhere.
<span
className="pointer-events-none absolute right-7 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center text-emerald-600 duration-150 animate-in fade-in zoom-in dark:text-emerald-500"
role="img"
aria-label={t("settings.general.tokenSaved")}
>
<Check className="size-4" strokeWidth={2.5} />
</span>
) : null}
<button
type="button"
onClick={() => setShowToken((s) => !s)}

View file

@ -24,7 +24,10 @@ import {
useTrainingRuntimeStore,
} from "@/features/training";
import { formatDuration } from "@/features/studio/sections/progress-section-lib";
import { fetchDeviceType, usePlatformStore } from "@/config/env";
import { cn } from "@/lib/utils";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { toast } from "@/lib/toast";
import { Delete02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
@ -194,6 +197,28 @@ export function HistoryCardGrid({
const [manualFetchInFlight, setManualFetchInFlight] = useState(false);
const { resumeTrainingRunFromHistory } = useTrainingActions();
const isStarting = useTrainingRuntimeStore((state) => state.isStarting);
// Copy-link base: Cloudflare tunnel > LAN host:port > origin. The tunnel
// registers shortly after startup, so poll (bounded) until it shows.
const cloudflareUrl = usePlatformStore((s) => s.cloudflareUrl);
const serverUrl = usePlatformStore((s) => s.serverUrl);
useEffect(() => {
if (cloudflareUrl) return;
let cancelled = false;
void (async () => {
for (let attempt = 0; attempt < 12 && !cancelled; attempt++) {
try {
await fetchDeviceType({ force: true });
} catch {
// Ignore startup blips; copy-link falls back to serverUrl/origin.
}
if (cancelled || usePlatformStore.getState().cloudflareUrl) return;
await new Promise((r) => setTimeout(r, 2500));
}
})();
return () => {
cancelled = true;
};
}, [cloudflareUrl]);
const userControllerRef = useRef<AbortController | null>(null);
const pollControllerRef = useRef<AbortController | null>(null);
@ -362,6 +387,8 @@ export function HistoryCardGrid({
const isRunning = run.status === "running";
const canResume = run.can_resume && !wasContinued;
const isResuming = resumeTarget === run.id;
// Backend /p ref, gated on previewability + route-expressible depth.
const canCopyPreview = !!run.preview_ref;
return (
<div
role="button"
@ -372,7 +399,7 @@ export function HistoryCardGrid({
isRunning
? "border-blue-400/50 dark:border-blue-500/30"
: "border-border/60",
canResume && "gap-2",
(canResume || canCopyPreview) && "gap-2",
)}
onClick={() => onSelectRun(run.id)}
onKeyDown={(e) => {
@ -411,6 +438,38 @@ export function HistoryCardGrid({
{isResuming ? t("studio.history.resuming") : t("studio.history.resumeTraining")}
</Button>
)}
{canCopyPreview && (
<Button
type="button"
size="xs"
variant="outline"
className="absolute bottom-3 right-4 h-6 rounded-full px-2.5 text-[11px] leading-none shadow-sm"
onClick={async (e) => {
e.stopPropagation();
// Encode each segment but keep "/" so the /p route matches.
const ref = (run.preview_ref ?? "")
.split("/")
.map(encodeURIComponent)
.join("/");
const base = (
cloudflareUrl ??
serverUrl ??
window.location.origin
).replace(/\/+$/, "");
const url = `${base}/p/${ref}`;
const ok = await copyToClipboard(url);
toast[ok ? "success" : "error"](
t(
ok
? "studio.history.previewLinkCopied"
: "studio.history.previewLinkCopyFailed",
),
);
}}
>
{t("studio.history.copyPreviewLink")}
</Button>
)}
<div className="min-w-0">
<p
className="truncate text-sm font-medium"
@ -434,7 +493,7 @@ export function HistoryCardGrid({
</p>
</div>
{run.loss_sparkline && run.loss_sparkline.length >= 2 && (
<div className={cn(canResume && "h-7 overflow-hidden")}>
<div className={cn((canResume || canCopyPreview) && "h-7 overflow-hidden")}>
<Sparkline
values={run.loss_sparkline}
id={run.id}

View file

@ -15,6 +15,8 @@ export interface TrainingRunSummary {
output_dir: string | null;
can_resume: boolean;
resumed_later: boolean;
has_preview_model: boolean;
preview_ref: string | null;
duration_seconds: number | null;
error_message: string | null;
loss_sparkline: number[] | null;

View file

@ -23,6 +23,7 @@ export const en = {
brand: "unsloth",
product: "Unsloth Studio",
accountMenu: "{name} account menu",
updateAvailable: "Update available",
aria: {
home: "Unsloth home",
closeSidebar: "Close sidebar",
@ -106,6 +107,7 @@ export const en = {
"Used to load gated models and push artifacts.",
hideToken: "Hide token",
showToken: "Show token",
tokenSaved: "Token saved",
password: "Password",
passwordDescription: "Change the password for this Studio account.",
passwordDialog: {
@ -762,6 +764,9 @@ export const en = {
running: "Training in progress",
errored: "Training errored",
},
copyPreviewLink: "Copy preview link",
previewLinkCopied: "Preview link copied",
previewLinkCopyFailed: "Couldn't copy the link",
},
charts: {
settings: "Chart Settings",

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