Merge branch 'main' into studio-model-idle-ttl

This commit is contained in:
Daniel Han 2026-06-23 08:14:27 -07:00 committed by GitHub
commit 6590f18638
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
120 changed files with 4914 additions and 1246 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

@ -2146,7 +2146,7 @@ exit 0
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" }
if ($baseInstallExit -eq 0) {
# Resolve pydantic WITH deps so pip pins pydantic-core
# to the matching version (no-torch-runtime.txt below
@ -2160,7 +2160,7 @@ exit 0
}
}
} else {
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" }
}
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
@ -2226,7 +2226,7 @@ exit 0
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" }
if ($baseInstallExit -eq 0) {
# Same pydantic-with-deps trick as the migrated branch.
$baseInstallExit = Invoke-InstallCommandRetry -Label "install pydantic" { uv pip install --python $VenvPython pydantic }
@ -2238,7 +2238,7 @@ exit 0
}
}
} elseif ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" }
} else {
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth" { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
}
@ -2266,7 +2266,7 @@ exit 0
Write-TauriLog "STEP" "Installing unsloth"
substep "installing unsloth (this may take a few minutes)..."
if ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.6.6" "unsloth>=2026.6.8" --torch-backend=auto }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.6.7" "unsloth>=2026.6.9" --torch-backend=auto }
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
@ -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 to allow HTTPS)"
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 to allow HTTPS)"
Write-Host ""
}
}

View file

@ -2621,7 +2621,7 @@ if [ "$_MIGRATED" = true ]; then
# to prevent transitive torch resolution.
run_install_cmd_retry "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6"
"unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7"
# Resolve pydantic WITH deps so pip pins pydantic-core to the
# matching version (no-torch-runtime.txt below is --no-deps).
# All transitive deps are torch-free.
@ -2634,7 +2634,7 @@ if [ "$_MIGRATED" = true ]; then
else
run_install_cmd_retry "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6"
"unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7"
fi
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
substep "overlaying local repo (editable)..."
@ -2838,7 +2838,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
run_install_cmd_retry "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--upgrade-package unsloth --upgrade-package unsloth-zoo \
"unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6"
"unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7"
# Same pydantic-with-deps trick as the migrated branch.
run_install_cmd_retry "install pydantic (with deps for compatible core)" \
uv pip install --python "$_VENV_PY" pydantic
@ -2856,7 +2856,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
fi
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
run_install_cmd_retry "install unsloth (local)" uv pip install --python "$_VENV_PY" \
--upgrade-package unsloth "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6"
--upgrade-package unsloth "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7"
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
substep "overlaying unsloth-zoo from git main..."
@ -2888,7 +2888,7 @@ else
tauri_log "STEP" "Installing Unsloth"
substep "installing unsloth (this may take a few minutes)..."
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.6.6" "unsloth>=2026.6.8" --torch-backend=auto
run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.6.7" "unsloth>=2026.6.9" --torch-backend=auto
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
substep "overlaying unsloth-zoo from git main..."
@ -3160,6 +3160,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 to allow HTTPS)"
echo ""
;;
esac
@ -3181,5 +3182,6 @@ else
substep "unsloth studio -p 8888"
fi
substep "(add -H 0.0.0.0 to allow network / cloud access)"
substep "(add --secure to allow HTTPS)"
echo ""
fi

View file

@ -71,7 +71,7 @@ triton = [
]
huggingfacenotorch = [
"unsloth_zoo>=2026.6.6",
"unsloth_zoo>=2026.6.7",
"wheel>=0.42.0",
"packaging",
"numpy",
@ -92,7 +92,7 @@ huggingfacenotorch = [
]
huggingface = [
"unsloth[huggingfacenotorch]",
"unsloth_zoo>=2026.6.6",
"unsloth_zoo>=2026.6.7",
"torchvision",
"unsloth[triton]",
]
@ -582,7 +582,7 @@ colab-ampere-torch220 = [
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
]
colab-new = [
"unsloth_zoo>=2026.6.6",
"unsloth_zoo>=2026.6.7",
"packaging",
"tyro",
"transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0",

View file

@ -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

@ -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

@ -7861,6 +7861,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,
@ -7998,6 +8007,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
@ -8172,6 +8184,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:
@ -8187,6 +8201,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
@ -8284,9 +8305,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 {
@ -8695,6 +8717,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
@ -8723,6 +8747,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 {
@ -8755,6 +8785,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>"
@ -8764,6 +8796,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

@ -2545,14 +2545,24 @@ def _python_exec(
pass
try:
fd, tmp_path = tempfile.mkstemp(suffix = ".py", prefix = "studio_exec_", dir = workdir)
with os.fdopen(fd, "w") as f:
# utf-8 so non-ASCII in model-written code survives the OS default codec
# (Windows cp1252 would otherwise raise UnicodeEncodeError).
with os.fdopen(fd, "w", encoding = "utf-8") as f:
f.write(code)
safe_env = _build_bypass_env(workdir) if disable_sandbox else _build_safe_env(workdir)
if disable_sandbox:
# Match the sandboxed Python path without changing bypass shell I/O.
safe_env = dict(safe_env)
safe_env["PYTHONIOENCODING"] = "utf-8"
popen_kwargs = dict(
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
# Decode child output as utf-8 (it emits utf-8 via PYTHONIOENCODING);
# replace so non-ASCII output never crashes the read on Windows.
encoding = "utf-8",
errors = "replace",
cwd = workdir,
env = safe_env,
)

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

@ -917,24 +917,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 = ["*"],

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

@ -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 # 4.14+ breaks cancel scope on Py3.13 (#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,13 +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
# anyio 4.14+ breaks cancel scope on Python 3.13 (#6483). Global cap so later
# with-deps steps (studio.txt, data-designer-deps.txt) 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

@ -3,3 +3,10 @@
# backtrack unsloth. Relax to match the pin -- per-model 5.x routing
# 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 (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

View file

@ -12,6 +12,7 @@ import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import StreamingResponse, JSONResponse, Response
from starlette.requests import ClientDisconnect
from typing import Any, List, Optional, Union
import json
import httpx
@ -235,8 +236,15 @@ def _sse_streaming_response(content) -> StreamingResponse:
a one-shot connection. Two callers build their response inline instead: the
external-provider proxy omits ``Connection: close``, and the OpenAI
passthrough returns an empty ``keep-alive`` stream when the request is
cancelled before the upstream response starts."""
return StreamingResponse(
cancelled before the upstream response starts.
Built on ``_SameTaskStreamingResponse`` (not Starlette's stock
``StreamingResponse``) so the SSE generator runs in the request task. The
legacy AnyIO task-group wrapper trips "Attempted to exit a cancel scope in a
different task" on Python 3.13 + httpx, which surfaced as a mid-stream
``response.failed``. The streaming paths that take their response inline use
``_SameTaskStreamingResponse`` directly for the same reason."""
return _SameTaskStreamingResponse(
content,
media_type = "text/event-stream",
headers = {
@ -750,6 +758,121 @@ def _set_stream_response_read_timeout(
pass
_STREAM_DISCONNECT_POLL_TIMEOUT_S = 0.25
class _CompatSameTaskTimeout:
"""Same-task timeout fallback for Python versions before asyncio.timeout."""
def __init__(self, timeout_s: float):
self.timeout_s = timeout_s
self._task = None
self._handle = None
self._timed_out = False
self._cancelling = 0
async def __aenter__(self):
self._task = asyncio.current_task()
if self._task is None:
return self
if hasattr(self._task, "cancelling"):
self._cancelling = self._task.cancelling()
loop = asyncio.get_running_loop()
self._handle = loop.call_later(max(self.timeout_s, 0), self._cancel_task)
return self
async def __aexit__(self, exc_type, exc, tb):
if self._handle is not None:
self._handle.cancel()
if exc_type is not None and issubclass(exc_type, asyncio.CancelledError):
if self._timed_out:
if self._task is not None and hasattr(self._task, "uncancel"):
if self._task.uncancel() > self._cancelling:
return None
raise asyncio.TimeoutError from exc
return None
def _cancel_task(self) -> None:
self._timed_out = True
if self._task is not None:
self._task.cancel()
def _same_task_timeout(timeout_s: float):
timeout_ctx = getattr(asyncio, "timeout", None)
if timeout_ctx is not None:
return timeout_ctx(timeout_s)
return _CompatSameTaskTimeout(timeout_s)
class _SameTaskStreamingResponse(StreamingResponse):
"""StreamingResponse without Starlette's legacy AnyIO task-group wrapper."""
def __init__(
self,
*args,
unstarted_cleanup = None,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
# Async callable invoked when the client disconnects before the body
# iterator is ever advanced. A generator that never started cannot run
# its own try/finally, so a stream that acquires resources before its
# first yield (the passthrough opens an upstream httpx stream eagerly)
# passes this to release them.
self._unstarted_cleanup = unstarted_cleanup
async def __call__(self, scope, receive, send) -> None:
# Track whether the body iterator was ever advanced: send() only emits a
# body message after the generator yields its first chunk, so a failure
# before then means it never entered its try/finally.
body_started = False
async def _tracking_send(message) -> None:
nonlocal body_started
if message.get("type") == "http.response.body":
body_started = True
await send(message)
try:
await self.stream_response(_tracking_send)
except OSError:
# Client disconnected mid-send.
if body_started:
# The generator produced at least one chunk and is suspended in
# its try/finally. Throw CancelledError into it (not aclose's
# GeneratorExit) so its `except asyncio.CancelledError` handler
# runs and finishes any api_monitor entry; GeneratorExit would
# skip it and only run `finally`. Fall back to aclose() without
# athrow.
athrow = getattr(self.body_iterator, "athrow", None)
if athrow is not None:
try:
await athrow(asyncio.CancelledError())
except (asyncio.CancelledError, StopAsyncIteration, RuntimeError):
pass
else:
aclose = getattr(self.body_iterator, "aclose", None)
if aclose is not None:
await aclose()
else:
# http.response.start failed before the body iterator advanced,
# so its try/finally never armed and aclose()/athrow() are no-ops
# on an unstarted generator. Release any resources acquired
# before the first yield via the explicit cleanup hook.
aclose = getattr(self.body_iterator, "aclose", None)
if aclose is not None:
await aclose()
if self._unstarted_cleanup is not None:
try:
await self._unstarted_cleanup()
except Exception:
pass
raise ClientDisconnect()
if self.background is not None:
await self.background()
async def _aclose_stream_resources(
*,
watchers = (),
@ -875,8 +998,23 @@ async def _aiter_llama_stream_items(
raise httpx.ReadTimeout("The model did not produce a first token in time.")
if response is not None:
_set_stream_response_read_timeout(response, remaining_s)
item = await asyncio.wait_for(async_iter.__anext__(), timeout = remaining_s)
# Keep httpx/httpcore's AnyIO cancel scope in this task.
# asyncio.wait_for would drive __anext__ in a child task.
async with _same_task_timeout(remaining_s):
item = await async_iter.__anext__()
else:
if (
request is not None
and response is not None
and post_first_item_read_timeout_s is not None
and last_item_at is not None
):
stall_remaining_s = post_first_item_read_timeout_s - (
time.monotonic() - last_item_at
)
if stall_remaining_s <= 0:
raise httpx.ReadTimeout("The model stopped producing tokens mid-response.")
_set_stream_response_read_timeout(response, stall_remaining_s)
item = await async_iter.__anext__()
except asyncio.TimeoutError as exc:
if waiting_first_item:
@ -890,6 +1028,12 @@ async def _aiter_llama_stream_items(
if now >= first_token_deadline:
raise
continue
if (
request is not None
and post_first_item_read_timeout_s is not None
and now - last_item_at < post_first_item_read_timeout_s
):
continue
raise httpx.ReadTimeout("The model stopped producing tokens mid-response.")
if (
last_item_at is None
@ -1125,16 +1269,17 @@ def _detect_safetensors_features(backend, chat_template: Optional[str]) -> dict:
model_identifier = model_id,
log_source = "safetensors",
)
# Our safetensors loop only parses <tool_call>{json}</tool_call> and
# <function=name>...</function>. Llama uses <|python_tag|>, Mistral uses
# [TOOL_CALLS]; advertising tools for those enables a pill the parser
# can't honour. GGUF is unaffected -- llama-server normalises every
# format into structured deltas.
# Our safetensors loop only parses <tool_call>{json}</tool_call>,
# <function=name>...</function>, and Gemma native <|tool_call>...<tool_call|>.
# Llama uses <|python_tag|>, Mistral uses [TOOL_CALLS]; advertising tools for
# those enables a pill the parser can't honour. GGUF is unaffected --
# llama-server normalises every format into structured deltas.
if (
flags.get("supports_tools")
and chat_template
and "<tool_call>" not in chat_template
and "<function=" not in chat_template
and "<|tool_call>" not in chat_template
):
logger.info(
"safetensors: template advertises tools but uses an "
@ -1297,6 +1442,24 @@ async def _await_disconnect_then_close(request, resp, cancel_event) -> None:
return
async def _await_disconnect_then_cancel(request, cancel_event) -> None:
"""Set ``cancel_event`` when a same-task local stream disconnects."""
try:
while not await request.is_disconnected():
await asyncio.sleep(0.1)
cancel_event.set()
except asyncio.CancelledError:
return
async def _stop_local_disconnect_cancel_watcher(watcher) -> None:
watcher.cancel()
try:
await watcher
except (asyncio.CancelledError, Exception):
pass
# Centralized local/server tool nudge. Keep render_html guidance gated to turns
# where the canvas tool is actually present in the tool schema; otherwise
# small local models can hallucinate a missing tool call instead of following
@ -1418,7 +1581,9 @@ _TOOL_XML_RE = _re.compile(
# Hyphen in the name char-class matches MCP tool names with dashes
# (mcp__srv__list-issues) that would otherwise leak past this strip.
r"<(?:tool_call|function=[\w-]+)>.*?(?:</(?:tool_call|function)>|\Z)"
r"|<\|tool_call>.*?(?:<tool_call\|>|\Z)"
r"|</(?:tool_call|function)>"
r"|<tool_call\|>"
r"|</parameter>\s*\Z",
_re.DOTALL,
)
@ -3234,7 +3399,9 @@ async def get_api_monitor_entry(entry_id: str, current_subject: str = Depends(ge
@router.post("/generate/stream")
async def generate_stream(
request: GenerateRequest, current_subject: str = Depends(get_current_subject)
request: GenerateRequest,
fastapi_request: Request,
current_subject: str = Depends(get_current_subject),
):
"""
Generate a chat response with Server-Sent Events (SSE) streaming.
@ -3284,6 +3451,13 @@ async def generate_stream(
async def stream():
gen = None
completed = False
# Cancel the generation when the client disconnects. The generator only
# awaits asyncio.to_thread(next, gen, ...), so without a concurrent
# watcher a disconnect during a long prefill/generation would go
# unnoticed until the next send and the backend would keep generating.
disconnect_watcher = asyncio.create_task(
_await_disconnect_then_cancel(fastapi_request, cancel_event)
)
try:
gen = backend.generate_chat_response(
messages = request.messages,
@ -3298,12 +3472,22 @@ async def generate_stream(
)
_DONE = object()
while True:
if cancel_event.is_set():
# The disconnect watcher set cancel_event between chunks.
# Reset the backend here: closing the Python generator does
# not signal a subprocess backend, so without this it keeps
# decoding after the client is gone. The finally's reset is
# guarded on cancel_event being unset, so it will not run
# again for this path.
backend.reset_generation_state()
break
chunk = await asyncio.to_thread(next, gen, _DONE)
if chunk is _DONE:
completed = True
break
yield f"data: {json.dumps({'content': chunk})}\n\n"
completed = True
yield "data: [DONE]\n\n"
if completed:
yield "data: [DONE]\n\n"
except asyncio.CancelledError:
cancel_event.set()
@ -3315,6 +3499,7 @@ async def generate_stream(
logger.error(f"Error during generation: {e}", exc_info = True)
yield f"data: {json.dumps({'error': _friendly_error(e)})}\n\n"
finally:
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
if not completed and not cancel_event.is_set():
cancel_event.set()
backend.reset_generation_state()
@ -4742,6 +4927,9 @@ async def openai_chat_completions(
_tracker.__enter__()
async def audio_input_stream():
disconnect_watcher = asyncio.create_task(
_await_disconnect_then_cancel(request, cancel_event)
)
try:
yield _chat_role_chunk(completion_id, created, model_name)
@ -4777,9 +4965,18 @@ async def openai_chat_completions(
api_monitor.fail(monitor_id, _friendly_error(e))
yield f"data: {json.dumps({'error': {'message': _friendly_error(e), 'type': 'server_error'}})}\n\n"
finally:
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
_tracker.__exit__(None, None, None)
return _sse_streaming_response(audio_input_stream())
return _SameTaskStreamingResponse(
audio_input_stream(),
media_type = "text/event-stream",
headers = {
"Cache-Control": "no-cache",
"Connection": "close",
"X-Accel-Buffering": "no",
},
)
else:
try:
full_text = "".join(audio_input_generate())
@ -4954,6 +5151,28 @@ async def openai_chat_completions(
completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
created = int(time.time())
def _new_chat_reasoning_extractor():
return _ResponsesReasoningExtractor(
parse_think_markers = _responses_should_parse_think_markers(
payload,
llama_backend,
)
)
def _gguf_chat_delta_line(delta: ChoiceDelta, finish_reason = None) -> str:
chunk = ChatCompletionChunk(
id = completion_id,
created = created,
model = model_name,
choices = [
ChunkChoice(
delta = delta,
finish_reason = finish_reason,
)
],
)
return f"data: {chunk.model_dump_json(exclude_none = True)}\n\n"
# ── Tool-calling path (agentic loop) ──────────────────
# `_effective_enable_tools` lets `unsloth run --enable-tools/--disable-tools`
# hard-override the per-request value, else falls back to
@ -5066,6 +5285,9 @@ async def openai_chat_completions(
async def gguf_tool_stream():
gen = None
disconnect_watcher = asyncio.create_task(
_await_disconnect_then_cancel(request, cancel_event)
)
try:
yield _chat_role_chunk(completion_id, created, model_name)
@ -5073,9 +5295,25 @@ async def openai_chat_completions(
# stays free for disconnect detection.
gen = gguf_generate_with_tools()
prev_text = ""
reasoning_extractor = _new_chat_reasoning_extractor()
_stream_usage = None
_stream_timings = None
_stream_finish = None
def _flush_reasoning_extractor():
final_reasoning, final_visible = reasoning_extractor.finish()
chunks = []
if final_reasoning:
chunks.append(
_gguf_chat_delta_line(
ChoiceDelta(reasoning_content = final_reasoning)
)
)
if final_visible:
api_monitor.append_reply(monitor_id, final_visible)
chunks.append(_gguf_chat_delta_line(ChoiceDelta(content = final_visible)))
return chunks
while True:
if cancel_event.is_set():
break
@ -5094,7 +5332,10 @@ async def openai_chat_completions(
# cumulative cursor so the next assistant turn
# streams cleanly.
if not event["text"]:
for chunk in _flush_reasoning_extractor():
yield chunk
prev_text = ""
reasoning_extractor = _new_chat_reasoning_extractor()
# Emit tool status as a custom SSE event (including
# empty ones to clear UI badges)
status_data = json.dumps(
@ -5108,7 +5349,10 @@ async def openai_chat_completions(
if event["type"] in ("tool_start", "tool_end"):
if event["type"] == "tool_start":
for chunk in _flush_reasoning_extractor():
yield chunk
prev_text = ""
reasoning_extractor = _new_chat_reasoning_extractor()
yield f"data: {json.dumps(event)}\n\n"
continue
@ -5118,6 +5362,11 @@ async def openai_chat_completions(
_stream_finish = event.get("finish_reason")
continue
if event["type"] == "reasoning_summary":
# Forward server-side reasoning timing to the UI.
yield f"data: {json.dumps(event)}\n\n"
continue
# "content" type -- cumulative text. Sanitize the full
# cumulative then diff against the last sanitized
# snapshot so cross-chunk XML tags are handled correctly.
@ -5130,15 +5379,33 @@ async def openai_chat_completions(
prev_text = clean_cumulative
if not new_text:
continue
api_monitor.append_reply(monitor_id, new_text)
yield _chat_content_chunk(completion_id, created, model_name, new_text)
reasoning_delta, visible_delta = reasoning_extractor.feed(new_text)
if reasoning_delta:
yield _gguf_chat_delta_line(
ChoiceDelta(reasoning_content = reasoning_delta)
)
if visible_delta:
api_monitor.append_reply(monitor_id, visible_delta)
yield _gguf_chat_delta_line(ChoiceDelta(content = visible_delta))
yield _chat_final_chunk(
completion_id,
created,
model_name,
_clamp_finish_reason(_stream_finish),
for chunk in _flush_reasoning_extractor():
yield chunk
final_chunk = ChatCompletionChunk(
id = completion_id,
created = created,
model = model_name,
choices = [
ChunkChoice(
delta = ChoiceDelta(),
finish_reason = _clamp_finish_reason(_stream_finish),
)
],
)
# Emit the terminal chunk carrying finish_reason before the
# optional usage chunk and [DONE], so OpenAI-compatible
# clients can detect stop/length/tool_calls.
yield f"data: {final_chunk.model_dump_json(exclude_none = True)}\n\n"
usage_line = _openai_stream_usage_chunk(
payload,
completion_id,
@ -5167,6 +5434,7 @@ async def openai_chat_completions(
error_chunk = _openai_stream_error_chunk(e)
yield f"data: {json.dumps(error_chunk)}\n\n"
finally:
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
if gen is not None:
try:
gen.close()
@ -5174,7 +5442,15 @@ async def openai_chat_completions(
pass
_tracker.__exit__(None, None, None)
return _sse_streaming_response(gguf_tool_stream())
return _SameTaskStreamingResponse(
gguf_tool_stream(),
media_type = "text/event-stream",
headers = {
"Cache-Control": "no-cache",
"Connection": "close",
"X-Accel-Buffering": "no",
},
)
# ── Standard GGUF path (no tools) ─────────────────────
@ -5210,6 +5486,9 @@ async def openai_chat_completions(
_tracker.__enter__()
async def gguf_stream_chunks():
disconnect_watcher = asyncio.create_task(
_await_disconnect_then_cancel(request, cancel_event)
)
try:
yield _chat_role_chunk(completion_id, created, model_name)
@ -5217,6 +5496,7 @@ async def openai_chat_completions(
# stays free for disconnect detection.
gen = gguf_generate()
prev_text = ""
reasoning_extractor = _new_chat_reasoning_extractor()
_stream_usage = None
_stream_timings = None
_stream_finish = None
@ -5250,15 +5530,38 @@ async def openai_chat_completions(
prev_text = cumulative
if not new_text:
continue
api_monitor.append_reply(monitor_id, new_text)
yield _chat_content_chunk(completion_id, created, model_name, new_text)
reasoning_delta, visible_delta = reasoning_extractor.feed(new_text)
if reasoning_delta:
yield _gguf_chat_delta_line(
ChoiceDelta(reasoning_content = reasoning_delta)
)
if visible_delta:
api_monitor.append_reply(monitor_id, visible_delta)
yield _gguf_chat_delta_line(ChoiceDelta(content = visible_delta))
yield _chat_final_chunk(
completion_id,
created,
model_name,
_clamp_finish_reason(_stream_finish),
final_reasoning, final_visible = reasoning_extractor.finish()
if final_reasoning:
yield _gguf_chat_delta_line(ChoiceDelta(reasoning_content = final_reasoning))
if final_visible:
api_monitor.append_reply(monitor_id, final_visible)
yield _gguf_chat_delta_line(ChoiceDelta(content = final_visible))
# Final chunk
final_chunk = ChatCompletionChunk(
id = completion_id,
created = created,
model = model_name,
choices = [
ChunkChoice(
delta = ChoiceDelta(),
finish_reason = _clamp_finish_reason(_stream_finish),
)
],
)
# Emit the terminal chunk carrying finish_reason before the
# optional usage chunk and [DONE], so OpenAI-compatible
# clients can detect stop/length/tool_calls.
yield f"data: {final_chunk.model_dump_json(exclude_none = True)}\n\n"
usage_line = _openai_stream_usage_chunk(
payload,
completion_id,
@ -5285,9 +5588,18 @@ async def openai_chat_completions(
error_chunk = _openai_stream_error_chunk(e)
yield f"data: {json.dumps(error_chunk)}\n\n"
finally:
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
_tracker.__exit__(None, None, None)
return _sse_streaming_response(gguf_stream_chunks())
return _SameTaskStreamingResponse(
gguf_stream_chunks(),
media_type = "text/event-stream",
headers = {
"Cache-Control": "no-cache",
"Connection": "close",
"X-Accel-Buffering": "no",
},
)
else:
try:
# ``n`` requests several independent completions; the single
@ -5314,14 +5626,24 @@ async def openai_chat_completions(
continue
full_text = token
reasoning_text, visible_text = _extract_responses_reasoning(
full_text,
parse_think_markers = _responses_should_parse_think_markers(
payload,
llama_backend,
),
)
message_kwargs = {"content": visible_text}
if reasoning_text:
message_kwargs["reasoning_content"] = reasoning_text
_choices.append(
CompletionChoice(
index = _idx,
message = CompletionMessage(content = full_text),
message = CompletionMessage(**message_kwargs),
finish_reason = _clamp_finish_reason(completion_finish),
)
)
_monitor_replies.append(full_text)
_monitor_replies.append(visible_text)
if completion_usage:
# The prompt is shared across all n choices, so count its
# tokens ONCE (OpenAI bills only generated tokens for each
@ -5343,7 +5665,7 @@ async def openai_chat_completions(
prompt_tokens_details = _prompt_tokens_details(_prompt_details),
),
)
monitor_reply = full_text
monitor_reply = _monitor_replies[-1] if _monitor_replies else ""
if _n > 1:
monitor_reply = "\n\n".join(
f"Choice {_idx + 1}:\n{text}" for _idx, text in enumerate(_monitor_replies)
@ -5553,6 +5875,9 @@ async def openai_chat_completions(
async def sf_tool_stream():
gen = None
disconnect_watcher = asyncio.create_task(
_await_disconnect_then_cancel(request, cancel_event)
)
try:
yield _chat_role_chunk(completion_id, created, model_name)
@ -5644,6 +5969,7 @@ async def openai_chat_completions(
}
yield f"data: {json.dumps(error_chunk)}\n\n"
finally:
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
if gen is not None:
try:
gen.close()
@ -5652,7 +5978,15 @@ async def openai_chat_completions(
_sf_tracker.__exit__(None, None, None)
if payload.stream:
return _sse_streaming_response(sf_tool_stream())
return _SameTaskStreamingResponse(
sf_tool_stream(),
media_type = "text/event-stream",
headers = {
"Cache-Control": "no-cache",
"Connection": "close",
"X-Accel-Buffering": "no",
},
)
# Non-streaming JSON: drain the loop, build one ChatCompletion.
try:
@ -5754,6 +6088,9 @@ async def openai_chat_completions(
_tracker.__enter__()
async def stream_chunks():
disconnect_watcher = asyncio.create_task(
_await_disconnect_then_cancel(request, cancel_event)
)
try:
yield _chat_role_chunk(completion_id, created, model_name)
@ -5830,9 +6167,18 @@ async def openai_chat_completions(
}
yield f"data: {json.dumps(error_chunk)}\n\n"
finally:
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
_tracker.__exit__(None, None, None)
return _sse_streaming_response(stream_chunks())
return _SameTaskStreamingResponse(
stream_chunks(),
media_type = "text/event-stream",
headers = {
"Cache-Control": "no-cache",
"Connection": "close",
"X-Accel-Buffering": "no",
},
)
# ── Non-streaming response ────────────────────────────────────
else:
@ -6552,8 +6898,9 @@ def _responses_should_parse_think_markers(
if llama_backend is not None and getattr(llama_backend, "is_loaded", False):
if getattr(llama_backend, "reasoning_always_on", False):
return True
if not getattr(llama_backend, "supports_reasoning", False):
return False
if getattr(llama_backend, "supports_reasoning", False):
return True
return False
if chat_req.enable_thinking is True:
return True
return chat_req.enable_thinking is None and chat_req.reasoning_effort not in (None, "none")
@ -6849,8 +7196,6 @@ async def _responses_non_streaming(
# the model produced content, so clients expecting a pure tool-call turn
# (finish_reason="tool_calls") don't see a spurious empty message item.
output_items: list[dict] = []
if reasoning_text and not text and not tool_calls:
text = reasoning_text
if reasoning_text:
output_items.append(_responses_reasoning_output_item(reasoning_text))
if text:
@ -7163,8 +7508,8 @@ async def _responses_stream(
client = httpx.AsyncClient(timeout = _llama_streaming_generation_timeout())
resp = None
lines_iter = None
disconnect_event = threading.Event()
disconnect_watcher = None
disconnect_event = threading.Event()
try:
req = client.build_request(
"POST", target_url, json = body, headers = {"Connection": "close"}
@ -7224,10 +7569,10 @@ async def _responses_stream(
)
return
lines_iter = resp.aiter_lines()
disconnect_watcher = asyncio.create_task(
_await_disconnect_then_close(request, resp, disconnect_event)
)
lines_iter = resp.aiter_lines()
async for raw_line in _aiter_llama_stream_items(
lines_iter,
cancel_event = disconnect_event,
@ -7347,6 +7692,7 @@ async def _responses_stream(
_apply_usage(chunk_data.get("usage"))
except asyncio.CancelledError:
disconnect_event.set()
api_monitor.finish(monitor_id, "cancelled")
raise
except (httpx.RemoteProtocolError, httpx.ReadError, httpx.CloseError) as e:
@ -7413,21 +7759,6 @@ async def _responses_stream(
"delta": final_visible,
},
)
if full_reasoning and not full_text and not tool_call_state:
for event in _ensure_message_open():
yield event
full_text = full_reasoning
api_monitor.set_reply(monitor_id, full_text)
yield _sse(
"response.output_text.delta",
{
"type": "response.output_text.delta",
"item_id": message_state["item_id"],
"output_index": message_state["output_index"],
"content_index": 0,
"delta": full_text,
},
)
close_items: list[tuple[int, str, dict[str, Any]]] = []
if reasoning_state["opened"]:
@ -7588,7 +7919,15 @@ async def _responses_stream(
api_monitor.finish(monitor_id)
yield _sse("response.completed", completed_response)
return _sse_streaming_response(event_generator())
return _SameTaskStreamingResponse(
event_generator(),
media_type = "text/event-stream",
headers = {
"Cache-Control": "no-cache",
"Connection": "close",
"X-Accel-Buffering": "no",
},
)
@router.post("/responses")
@ -8204,9 +8543,17 @@ async def _anthropic_tool_stream(
drop_until_tool_end = False
gen = run_gen()
# Concurrent disconnect watcher: the loop only polls is_disconnected()
# between events, so a client disconnect during a long prefill or
# generation step would otherwise hold the decode slot until the next
# event or a failed send. The watcher sets cancel_event so the backend
# stops promptly.
disconnect_watcher = asyncio.create_task(
_await_disconnect_then_cancel(request, cancel_event)
)
try:
while True:
if await request.is_disconnected():
if cancel_event.is_set() or await request.is_disconnected():
cancel_event.set()
return
event = await asyncio.to_thread(next, gen, _sentinel)
@ -8254,6 +8601,8 @@ async def _anthropic_tool_stream(
if _error_event is not None:
yield _error_event
return
finally:
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
stop_reason = openai_finish_to_anthropic_stop(
captured_finish_reason, had_tool_calls = ends_on_tool_use
@ -8290,9 +8639,17 @@ async def _anthropic_plain_stream(
captured_finish_reason = None
gen = run_gen()
# Concurrent disconnect watcher: the loop only polls is_disconnected()
# between chunks, so a client disconnect during a long prefill or
# generation step would otherwise hold the decode slot until the next
# chunk or a failed send. The watcher sets cancel_event so the backend
# stops promptly.
disconnect_watcher = asyncio.create_task(
_await_disconnect_then_cancel(request, cancel_event)
)
try:
while True:
if await request.is_disconnected():
if cancel_event.is_set() or await request.is_disconnected():
cancel_event.set()
return
cumulative = await asyncio.to_thread(next, gen, _sentinel)
@ -8315,6 +8672,8 @@ async def _anthropic_plain_stream(
if _error_event is not None:
yield _error_event
return
finally:
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
stop_reason = openai_finish_to_anthropic_stop(captured_finish_reason, had_tool_calls = False)
for line in emitter.finish(stop_reason = stop_reason, stop_sequence = None):
@ -9206,7 +9565,7 @@ async def _openai_passthrough_stream(
except Exception:
pass
_tracker.__exit__(None, None, None)
return StreamingResponse(
return _SameTaskStreamingResponse(
iter(()),
media_type = "text/event-stream",
headers = {
@ -9257,6 +9616,29 @@ async def _openai_passthrough_stream(
_await_disconnect_then_close(request, resp, cancel_event)
)
monitor_done = False
saw_finish_reason = False
saw_done = False
saw_stream_error = False
saw_tool_call_delta = False
last_chunk_id = completion_id
last_chunk_model = model_name
last_chunk_created = int(time.time())
def _synthetic_finish_line() -> str:
finish_reason = "tool_calls" if saw_tool_call_delta else "stop"
chunk = ChatCompletionChunk(
id = last_chunk_id,
created = last_chunk_created,
model = last_chunk_model,
choices = [
ChunkChoice(
delta = ChoiceDelta(),
finish_reason = finish_reason,
)
],
)
return f"data: {chunk.model_dump_json(exclude_none = True)}"
try:
lines_iter = resp.aiter_lines()
async for raw_line in _aiter_llama_stream_items(
@ -9270,23 +9652,117 @@ async def _openai_passthrough_stream(
continue
if not raw_line.startswith("data: "):
continue
data_text = raw_line[6:].strip()
if data_text == "[DONE]":
saw_done = True
if (
not saw_finish_reason
and not saw_stream_error
and not cancel_event.is_set()
):
finish_line = _synthetic_finish_line()
_monitor_openai_sse_line(
monitor_id,
finish_line,
llama_backend.context_length,
)
yield finish_line + "\n\n"
saw_finish_reason = True
_monitor_openai_sse_line(
monitor_id,
raw_line,
llama_backend.context_length,
)
yield raw_line + "\n\n"
monitor_done = True
break
# Honor parallel_tool_calls=false (best-effort): drop tool_call
# deltas with index>=1 so only the first call streams. Only
# lines carrying tool_calls are reparsed; everything else is
# relayed byte-for-byte.
if payload.parallel_tool_calls is False and '"tool_calls"' in raw_line:
raw_line = _cap_parallel_tool_calls_sse_line(raw_line)
data_text = raw_line[6:].strip()
try:
chunk_data = json.loads(data_text)
except json.JSONDecodeError:
chunk_data = None
if isinstance(chunk_data, dict):
if isinstance(chunk_data.get("id"), str):
last_chunk_id = chunk_data["id"]
if isinstance(chunk_data.get("model"), str):
last_chunk_model = chunk_data["model"]
if isinstance(chunk_data.get("created"), int):
last_chunk_created = chunk_data["created"]
choices = chunk_data.get("choices")
if isinstance(choices, list) and choices:
choice = choices[0]
if isinstance(choice, dict):
if choice.get("finish_reason"):
saw_finish_reason = True
delta = choice.get("delta")
if isinstance(delta, dict) and delta.get("tool_calls"):
saw_tool_call_delta = True
# Detect an upstream error chunk independently of API
# monitoring: when monitor_id is None (skip_api_monitor),
# _monitor_openai_sse_line returns before inspecting the
# error, so without this the synthetic-finish guard would
# emit a successful finish_reason after a failed stream.
if _monitor_openai_error_message(chunk_data):
saw_stream_error = True
monitor_event = _monitor_openai_sse_line(
monitor_id,
raw_line,
llama_backend.context_length,
)
if monitor_event == "error":
saw_stream_error = True
# If a trailing usage-only chunk (include_usage) arrives before
# any finish chunk, emit the synthetic finish first so the order
# stays finish -> usage -> [DONE], matching the other streams.
if (
isinstance(chunk_data, dict)
and chunk_data.get("usage")
and not (
isinstance(chunk_data.get("choices"), list) and chunk_data["choices"]
)
and not saw_finish_reason
and not saw_stream_error
and not cancel_event.is_set()
):
finish_line = _synthetic_finish_line()
_monitor_openai_sse_line(
monitor_id, finish_line, llama_backend.context_length
)
yield finish_line + "\n\n"
saw_finish_reason = True
# Relay verbatim to preserve llama-server's native id,
# finish_reason, delta.tool_calls, and usage chunks.
yield raw_line + "\n\n"
if monitor_event == "done" or raw_line[6:].strip() == "[DONE]":
if monitor_event == "done":
monitor_done = True
break
if not saw_done and not saw_stream_error and not cancel_event.is_set():
# Synthesize a finish chunk only if one was not already
# emitted (e.g. before a trailing usage-only chunk), but
# always close with [DONE] whenever the upstream omitted it,
# so the stream ends on the [DONE] sentinel either way.
if not saw_finish_reason:
finish_line = _synthetic_finish_line()
_monitor_openai_sse_line(
monitor_id,
finish_line,
llama_backend.context_length,
)
yield finish_line + "\n\n"
done_line = "data: [DONE]"
_monitor_openai_sse_line(
monitor_id,
done_line,
llama_backend.context_length,
)
yield done_line + "\n\n"
monitor_done = True
if not monitor_done:
api_monitor.finish(
monitor_id,
@ -9322,7 +9798,24 @@ async def _openai_passthrough_stream(
)
_tracker.__exit__(None, None, None)
return _sse_streaming_response(_stream())
async def _unstarted_cleanup() -> None:
# Client disconnected before the body stream started, so _stream()'s
# finally never ran. Release the eagerly-opened upstream resp/client
# and the cancel-registry entry here; the watchers and line iterator
# are created inside _stream(), so there is nothing else to close.
await _aclose_stream_resources(resp = resp, client = client)
_tracker.__exit__(None, None, None)
return _SameTaskStreamingResponse(
_stream(),
media_type = "text/event-stream",
headers = {
"Cache-Control": "no-cache",
"Connection": "close",
"X-Accel-Buffering": "no",
},
unstarted_cleanup = _unstarted_cleanup,
)
except BaseException:
_tracker.__exit__(None, None, None)
raise

View file

@ -893,9 +893,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 +924,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 +938,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 +983,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
@ -1158,7 +1171,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

@ -135,6 +135,7 @@ def test_python_bypass_uses_bypass_preexec_and_bypass_env(captured_popen, monkey
assert captured_popen["kwargs"]["preexec_fn"] is tools._bypass_preexec
env = captured_popen["kwargs"]["env"]
assert env.get("HOSTVAR") == "benign-xyz"
assert env.get("PYTHONIOENCODING") == "utf-8"
assert "HF_TOKEN" not in env
@ -151,9 +152,12 @@ def test_bash_blocklist_skipped_when_bypassed(captured_popen):
@_POSIX_ONLY
def test_bash_bypass_uses_bypass_preexec(captured_popen):
def test_bash_bypass_uses_bypass_preexec(captured_popen, monkeypatch):
# bypass inherits benign host vars; clear so we assert _bash_exec adds none.
monkeypatch.delenv("PYTHONIOENCODING", raising = False)
_bash_exec("echo hi", None, 5, "t", disable_sandbox = True)
assert captured_popen["kwargs"]["preexec_fn"] is tools._bypass_preexec
assert "PYTHONIOENCODING" not in captured_popen["kwargs"]["env"]
# ── real end-to-end python execution under bypass ───────────────────

View file

@ -0,0 +1,33 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""_python_exec must round-trip non-ASCII output end to end.
Model-written code routinely contains non-ASCII (arrows, CJK, emoji). The temp
script and the child's stdout pipe both have to be UTF-8 or it crashes/garbles
on Windows, whose default codec is cp1252. Mirrors the report in
unslothai/unsloth#6489. The child is ``python`` with PYTHONIOENCODING=utf-8, so
it emits UTF-8 on every OS; this proves the round-trip on a UTF-8 host and
guards against a regression to the OS default codec.
"""
import sys
from pathlib import Path
import pytest
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
from core.inference.tools import _python_exec
# Arrow, em-dash, accent, CJK, check mark, astral-plane emoji -- none encodable
# in cp1252, so the OS default codec would raise on write or read.
_UNICODE = "café — 数字 → ✓ 😀"
@pytest.mark.parametrize("disable_sandbox", [False, True])
def test_python_exec_round_trips_non_ascii(disable_sandbox):
out = _python_exec(f"print({_UNICODE!r})", disable_sandbox = disable_sandbox)
assert _UNICODE in out, repr(out)

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

@ -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

@ -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."""

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.

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
@ -1390,6 +1464,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 +1772,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 +2255,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 +2273,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 +2416,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

@ -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

@ -445,20 +445,40 @@ def test_pre_import_gate_is_transformers_free():
import utils.security.file_security as fs
import utils.security.consent as consent
for m in list(_sys.modules):
if m == "transformers" or m.startswith("transformers.") or m == "utils.models.model_config":
def _is_gated_module(name: str) -> bool:
return (
name == "transformers"
or name.startswith("transformers.")
or name == "utils.models.model_config"
)
# Snapshot then remove the modules so we can assert the gate does not re-import them.
# Restore the originals afterwards (finally): popping utils.models.model_config without
# restoring it makes a later importer get a fresh instance, so tests that patched the
# first instance (e.g. test_vision_cache) miss and hit the real network path.
_saved = {m: _sys.modules[m] for m in list(_sys.modules) if _is_gated_module(m)}
for m in _saved:
_sys.modules.pop(m, None)
try:
with patch.object(fs, "_fetch_security_status", return_value = None):
fs.evaluate_file_security("nvidia/Nemotron-H-8B", load_subdirs = ())
with patch.object(
consent, "_load_remote_code_configs", return_value = [{"model_type": "nemotron_h"}]
):
from utils.security import evaluate_remote_code_consent_for_targets
evaluate_remote_code_consent_for_targets(
["nvidia/Nemotron-H-8B"], trust_remote_code = True
)
assert "transformers" not in _sys.modules
assert "utils.models.model_config" not in _sys.modules
finally:
# Drop anything the gate imported, then rebind the original module objects so later
# tests see the same instances they captured at import time.
for m in [m for m in list(_sys.modules) if _is_gated_module(m) and m not in _saved]:
_sys.modules.pop(m, None)
with patch.object(fs, "_fetch_security_status", return_value = None):
fs.evaluate_file_security("nvidia/Nemotron-H-8B", load_subdirs = ())
with patch.object(
consent, "_load_remote_code_configs", return_value = [{"model_type": "nemotron_h"}]
):
from utils.security import evaluate_remote_code_consent_for_targets
evaluate_remote_code_consent_for_targets(["nvidia/Nemotron-H-8B"], trust_remote_code = True)
assert "transformers" not in _sys.modules
assert "utils.models.model_config" not in _sys.modules
_sys.modules.update(_saved)
def test_pre_import_gate_skips_subdir_computation():

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

@ -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

@ -49,6 +49,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 +184,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"
@ -191,7 +250,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],

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

@ -182,7 +182,9 @@ function RootLayout() {
chatRuntime.setActiveThreadId(null);
chatRuntime.setActiveProjectId(null);
chatRuntime.setIncognito(false);
if (chatRuntime.pendingSelection) chatRuntime.abandonStagedModel();
// Detach the staging UI but keep any in-flight download running, like Hub.
if (chatRuntime.pendingSelection)
chatRuntime.abandonStagedModel({ keepDownload: true });
void navigate({
to: "/chat",
search: { new: crypto.randomUUID() },
@ -205,7 +207,10 @@ function RootLayout() {
chatRuntime.setActiveProjectId(null);
chatRuntime.setActiveThreadId(null);
chatRuntime.setIncognito(false);
if (chatRuntime.pendingSelection) chatRuntime.abandonStagedModel();
// Leaving chat must not kill an in-flight download: detach the staging UI
// but keep the transfer running in the manager, like a Hub download.
if (chatRuntime.pendingSelection)
chatRuntime.abandonStagedModel({ keepDownload: true });
}, [isChatRoute]);
return (

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,
@ -253,6 +256,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();
@ -265,6 +281,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);
@ -1348,7 +1374,7 @@ export function AppSidebar() {
)}
</SidebarContent>
<SidebarFooter className="relative group-data-[collapsible=icon]:px-0">
<SidebarFooter className="relative pt-3 pb-4 group-data-[collapsible=icon]:px-0">
{/* 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. */}
@ -1359,7 +1385,54 @@ export function AppSidebar() {
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>
@ -1381,11 +1454,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

@ -2336,7 +2336,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 +2345,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 +2391,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",
)}
@ -3362,7 +3367,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 {
@ -18,6 +22,10 @@ import {
import { useSidebar } from "@/components/ui/sidebar";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { useLatestRef } from "@/features/hub/hooks/use-latest-ref";
import {
DOWNLOAD_KIND,
downloadManager,
} from "@/features/hub/download-manager";
import {
type NativeIntent,
NativeModelChip,
@ -1093,6 +1101,11 @@ export function ChatPage({
const abandonStaged = useCallback(() => {
useChatRuntimeStore.getState().abandonStagedModel();
}, []);
// Detach a staged pick on navigation without cancelling its download: the
// transfer keeps running in the manager and lands in cache, like Hub.
const detachStaged = useCallback(() => {
useChatRuntimeStore.getState().abandonStagedModel({ keepDownload: true });
}, []);
// Tracks whether the chat page is still mounted, so a staged-load failure that
// resolves after the user left chat doesn't resurrect the abandoned pick.
const mountedRef = useRef(true);
@ -1266,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();
@ -1620,8 +1638,8 @@ export function ChatPage({
const prev = prevChatContextRef.current;
prevChatContextRef.current = chatContextKey;
if (prev === null || prev === chatContextKey) return;
abandonStaged();
}, [chatContextKey, abandonStaged]);
detachStaged();
}, [chatContextKey, detachStaged]);
const hasActiveModel = Boolean(inferenceParams.checkpoint);
// Load immediately, or — when "Load on selection" is off — stage the pick so
@ -1639,25 +1657,81 @@ export function ChatPage({
(!hasGgufSource(selection) && !wantManagerDownload) ||
(store.loadOnSelection && selection.isDownloaded)
) {
// Abandon any staged pick first so its edited knobs (e.g. a custom
// 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.
abandonStaged();
await selectModel(selection);
// reads customContextLength before checking the target is GGUF. Detach
// (not abandon) keeps its download running.
detachStaged();
// 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;
}
// Refuse staging while a load is in flight (it would be silently dropped);
// the immediate-load branch above is already guarded in selectModel.
// Loads can't queue behind each other, but a download is independent: if
// the pick needs downloading, start it in the manager so it runs alongside
// the load. Nothing to download (already on device) just waits.
if (store.modelLoading) {
toast.info("Another model is already loading", {
description: "Wait for it to finish or cancel it first.",
});
// Both an uncached non-GGUF snapshot (wantManagerDownload) and an
// uncached remote GGUF quant download through the manager, so either can
// run in the background while another model loads. wantManagerDownload
// excludes GGUF by design, so the GGUF case is checked separately.
const wantBackgroundDownload =
wantManagerDownload ||
(selection.source === "hub" &&
hasGgufSource(selection) &&
!selection.isDownloaded);
// The model currently loading already downloads as part of its own load
// (the /load flow fetches before setting the checkpoint), so re-picking
// it must not kick off a second transfer against the same cache.
const isLoadingThisPick =
!!loadingModel &&
normalizeModelRef(loadingModel.id) ===
normalizeModelRef(selection.id) &&
(loadingModel.ggufVariant ?? null) === (selection.ggufVariant ?? null);
if (isLoadingThisPick) {
toast.info("This model is already loading", {
description: "It's downloading as part of the load in progress.",
});
} else if (wantBackgroundDownload) {
// Only claim the download started once a job is actually created. A
// transport conflict records state that is only resolvable from the
// Hub download card, so point the user there instead of showing a
// success toast for a transfer that never began; "busy" and "error"
// already surface their own toasts.
const outcome = await downloadManager.requestStart({
kind: DOWNLOAD_KIND.MODEL,
repoId: selection.id,
variant: selection.ggufVariant ?? null,
expectedBytes: selection.expectedBytes ?? 0,
});
if (outcome === "started") {
toast.info("Downloading in the background", {
description:
"It'll be ready to load once the current model finishes.",
});
} else if (outcome === "conflict") {
toast.info("Resume this download from the Hub", {
description:
"An earlier partial download used a different transport. Open the Hub tab to resume or restart it.",
});
}
} else {
toast.info("Another model is already loading", {
description: "Wait for it to finish or cancel it first.",
});
}
return;
}
// Tear down any existing staged pick first so its in-flight download is
// cancelled, not left running after we rebind to the new pick. With the
// toggle on, autoLoad downloads silently then loads; off stages for the sheet.
abandonStaged();
// Detach the prior staged pick (keeping its download) before rebinding, so
// a second pick downloads alongside the first instead of cancelling it.
detachStaged();
store.stageModel({
id: selection.id,
isLora: selection.isLora,
@ -1670,7 +1744,7 @@ export function ChatPage({
autoLoad: store.loadOnSelection,
});
},
[abandonStaged, selectModel],
[detachStaged, selectModel, loadingModel],
);
const loadNativeModelIntent = useCallback(
async (intent: NativeIntent, loadingDescription: string) => {
@ -2452,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

@ -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;
}

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;
@ -778,9 +783,10 @@ type ChatRuntimeStore = {
/** Stage a pick for a deferred load: revert knobs to the loaded baseline,
* record the selection, and open the settings sheet. */
stageModel: (selection: PendingModelSelection) => void;
/** Abandon a staged pick without loading: revert the knobs to the loaded
* baseline and clear the pending selection. */
abandonStagedModel: () => void;
/** Abandon a staged pick without loading: revert knobs to the loaded baseline
* and clear the pending selection. Cancels its in-flight download too, unless
* `keepDownload` is set (navigation keeps the transfer running, like Hub). */
abandonStagedModel: (opts?: { keepDownload?: boolean }) => void;
setCustomContextLength: (v: number | null) => void;
setChatTemplateOverride: (template: string | null) => void;
setPendingAudio: (base64: string, name: string) => void;
@ -1527,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 });
@ -1544,15 +1560,9 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
// Refuse staging mid-load: post-load cleanup would silently drop the queued
// pick. stageOrLoad toasts first for callers that can.
if (get().modelLoading) return;
// Rebinding to a new pick keeps the prior pick's download running so the
// user can queue multiple downloads at once (Hub-style).
set((s) => {
if (
s.pendingSelection &&
(s.pendingSelection.id !== selection.id ||
(s.pendingSelection.ggufVariant ?? null) !==
(selection.ggufVariant ?? null))
) {
cancelStagedModelDownload(s.pendingSelection);
}
return {
...loadedBaselineSettings(s),
pendingSelection: selection,
@ -1566,14 +1576,13 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
};
});
},
abandonStagedModel: () => {
abandonStagedModel: (opts) => {
const { pendingSelection } = get();
if (!pendingSelection) return;
// Cancel the staged pick's in-flight download so it doesn't keep running
// after the staging UI is gone. Centralized here so every abandon path
// (sheet close, thread switch, route exit, new chat) cancels it, including
// root-level callers that have no access to the useRepoDownload hook.
cancelStagedModelDownload(pendingSelection);
// Cancel the staged pick's in-flight download (centralized for every abandon
// path: sheet close, thread switch, route exit, new chat). `keepDownload`
// opts out so navigation leaves the transfer running, like a Hub download.
if (!opts?.keepDownload) cancelStagedModelDownload(pendingSelection);
set((s) => ({ ...loadedBaselineSettings(s), pendingSelection: null }));
},
setCustomContextLength: (customContextLength) => set({ customContextLength }),

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

@ -80,24 +80,50 @@ async function activeSiblingTransport(
return null;
}
// Outcome of a start request so callers can tell whether a transfer for this
// exact request is actually live before telling the user it began. "started"
// means a running/cancelling job exists for this key (a fresh start or an
// already-active one). "conflict" means a transport partial conflict was
// recorded and must be resolved from the Hub download card; "busy" means the
// repo is occupied by a sibling variant/snapshot/pending start that is not this
// transfer; "error" means the start failed or was refused.
export type DownloadStartOutcome = "started" | "conflict" | "busy" | "error";
// A start can no-op without throwing: the backend can refuse it (startJob
// finalizes "error"), startJob's peer guard can skip it, or
// hasActiveOrPendingStart can trip on a snapshot/peer/pending that is not this
// request. Derive the outcome from the actual job state of this exact key so
// callers never claim a download began when it did not.
function isJobActiveFor(req: DownloadRequest): boolean {
const job = getState().jobs[jobKeyOf(req.kind, req.repoId, req.variant)];
return Boolean(job && ACTIVE_STATES.has(job.state));
}
async function runWithPendingStartGuard(
req: DownloadRequest,
action: () => Promise<void>,
): Promise<void> {
action: () => Promise<DownloadStartOutcome>,
): Promise<DownloadStartOutcome> {
const startKey = pendingStartKey(req);
if (hasActiveOrPendingStart(req)) return;
// Already active or pending for the repo: only report "started" when this
// exact request is the live transfer; a peer/snapshot/pending start has not.
if (hasActiveOrPendingStart(req)) {
return isJobActiveFor(req) ? "started" : "busy";
}
runtimeRegistry.pendingStartRepoKeys.add(startKey);
try {
await action();
return await action();
} catch (error) {
reportConflictStartError(error);
return "error";
} finally {
runtimeRegistry.pendingStartRepoKeys.delete(startKey);
}
}
export async function requestStart(req: DownloadRequest): Promise<void> {
await runWithPendingStartGuard(req, async () => {
export async function requestStart(
req: DownloadRequest,
): Promise<DownloadStartOutcome> {
return runWithPendingStartGuard(req, async () => {
let mode: TransportMode = getTransportMode();
try {
mode = await effectiveTransportMode(mode);
@ -119,7 +145,7 @@ export async function requestStart(req: DownloadRequest): Promise<void> {
? "This repository is currently downloading with Xet. Switch to Xet or wait for it to finish."
: "This repository is currently downloading with HTTP. Switch to HTTP or wait for it to finish.",
});
return;
return "busy";
}
} catch (err) {
console.warn("Active download transport check failed.", err);
@ -139,7 +165,7 @@ export async function requestStart(req: DownloadRequest): Promise<void> {
},
pending: req,
});
return;
return "conflict";
}
if (status.has_partial && !status.last_transport) {
toast.info("Restarting this download", {
@ -163,7 +189,7 @@ export async function requestStart(req: DownloadRequest): Promise<void> {
"Starting with HTTP so an existing partial is not discarded. Switch transport to retry with Xet.",
});
await startJob(req, { useXet: false });
return;
return isJobActiveFor(req) ? "started" : "error";
}
toast.warning("Couldn't verify existing partial download", {
description:
@ -171,6 +197,7 @@ export async function requestStart(req: DownloadRequest): Promise<void> {
});
}
await startJob(req, { useXet: mode === TRANSPORT.XET });
return isJobActiveFor(req) ? "started" : "error";
});
}
@ -178,22 +205,24 @@ export function resumeConflict(conflictKey: string): void {
const entry = getState().conflicts[conflictKey];
if (!entry) return;
setConflict(conflictKey, null);
void runWithPendingStartGuard(entry.pending, () =>
startJob(entry.pending, {
void runWithPendingStartGuard(entry.pending, async () => {
await startJob(entry.pending, {
useXet: entry.info.previous === TRANSPORT.XET,
}),
);
});
return "started";
});
}
export function restartConflict(conflictKey: string): void {
const entry = getState().conflicts[conflictKey];
if (!entry) return;
setConflict(conflictKey, null);
void runWithPendingStartGuard(entry.pending, () =>
startJob(entry.pending, {
void runWithPendingStartGuard(entry.pending, async () => {
await startJob(entry.pending, {
useXet: entry.info.next === TRANSPORT.XET,
}),
);
});
return "started";
});
}
export function cancelConflict(conflictKey: string): void {

View file

@ -120,8 +120,10 @@ export function useRepoDownload(config: RepoDownloadConfig): DownloadJob {
);
const requestStartDownload = useCallback(
(variant: string | null, expectedBytes: number) => {
return downloadManager.requestStart({
async (variant: string | null, expectedBytes: number) => {
// This surface renders the conflict resolver (transportConflict), so the
// start outcome is handled by the card UI; the awaited result is ignored.
await downloadManager.requestStart({
kind,
repoId,
variant,

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,
@ -740,6 +749,23 @@ export function ModelsPage() {
() => (isDiscoverTab ? [] : tokenizeQuery(deferredDebouncedQuery)),
[isDiscoverTab, deferredDebouncedQuery],
);
// Hide infra models (e.g. the RAG embedder bge-small-en-v1.5) from the On
// Device list like Discover, but reveal a row when a query matches it so the
// user can confirm it is already downloaded.
const isVisibleInventoryRow = useCallback(
(row: CachedInventoryRow | LocalInventoryRow) =>
// Local rows can have a null repoId and an id that is a hash rather than
// the file path/name, so also check path/title (the backend's
// _is_hidden_model checks the on-disk path for the same reason).
!isHiddenModelId(
row.id,
row.repoId,
row.kind !== "cache" ? row.path : undefined,
row.kind !== "cache" ? row.title : undefined,
) ||
(inventoryTokens.length > 0 && inventoryRowMatches(row, inventoryTokens)),
[inventoryTokens],
);
// Format filter is a deliberate scope narrowing, so hard-filter it out. The
// text query instead drives dim-not-filter on On Device (see ModelsCatalog) so
// selection survives typing; matching rows are partitioned to the top.
@ -748,12 +774,22 @@ export function ModelsPage() {
partitionByMatch(
effectiveCachedRows.filter(
(row) =>
// Hidden-model filtering is model-only; datasets bypass it (and the
// format filter) the way Discover does, so a dataset whose
// id/title/path happens to contain an infra needle is not dropped.
isDatasetMode ||
matchesFormat(row.modelFormat, deferredFormatFilter),
(matchesFormat(row.modelFormat, deferredFormatFilter) &&
isVisibleInventoryRow(row)),
),
inventoryTokens,
),
[effectiveCachedRows, isDatasetMode, deferredFormatFilter, inventoryTokens],
[
effectiveCachedRows,
isDatasetMode,
deferredFormatFilter,
inventoryTokens,
isVisibleInventoryRow,
],
);
const filteredLocalRows = useMemo(
@ -761,12 +797,42 @@ export function ModelsPage() {
partitionByMatch(
effectiveLocalRows.filter(
(row) =>
// Hidden-model filtering is model-only; datasets bypass it (and the
// format filter) the way Discover does, so a dataset whose
// id/title/path happens to contain an infra needle is not dropped.
isDatasetMode ||
matchesFormat(row.modelFormat, deferredFormatFilter),
(matchesFormat(row.modelFormat, deferredFormatFilter) &&
isVisibleInventoryRow(row)),
),
inventoryTokens,
),
[effectiveLocalRows, isDatasetMode, deferredFormatFilter, inventoryTokens],
[
effectiveLocalRows,
isDatasetMode,
deferredFormatFilter,
inventoryTokens,
isVisibleInventoryRow,
],
);
// Header tallies exclude infra/hidden models so the count matches the On
// Device list (a fresh install with only the bge embedder cached reads 0,
// not 1 over an empty list). Reuse isVisibleInventoryRow so a hidden row
// revealed by an active search is counted too, and datasets (never infra)
// keep their full count, mirroring the row filter above.
const visibleCachedCount = useMemo(
() =>
effectiveCachedRows.filter(
(row) => isDatasetMode || isVisibleInventoryRow(row),
).length,
[effectiveCachedRows, isDatasetMode, isVisibleInventoryRow],
);
const visibleLocalCount = useMemo(
() =>
effectiveLocalRows.filter(
(row) => isDatasetMode || isVisibleInventoryRow(row),
).length,
[effectiveLocalRows, isDatasetMode, isVisibleInventoryRow],
);
const filterResetSignature = useMemo(
@ -850,7 +916,7 @@ export function ModelsPage() {
filteredCachedRows,
filteredLocalRows,
results: selectionResults,
accessToken: debouncedHfToken || undefined,
accessToken: apiHfToken,
online,
});
@ -1026,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(() => {
@ -1315,15 +1402,15 @@ export function ModelsPage() {
return (
<HubListHeader
title="On device"
count={effectiveCachedRows.length + effectiveLocalRows.length}
count={visibleCachedCount + visibleLocalCount}
view={allModelsView}
onViewChange={setAllModelsView}
actions={sortControl}
/>
);
}, [
effectiveCachedRows.length,
effectiveLocalRows.length,
visibleCachedCount,
visibleLocalCount,
allModelsView,
setAllModelsView,
inventorySort,
@ -1337,8 +1424,8 @@ export function ModelsPage() {
<div className="hub-page flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden bg-background">
<HubTopBar>
<ModelsHeader
cachedCount={effectiveCachedRows.length}
localCount={effectiveLocalRows.length}
cachedCount={visibleCachedCount}
localCount={visibleLocalCount}
isDataset={isDatasetMode}
gpuLabel={gpuLabel}
ramLabel={ramLabel}

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

@ -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",

View file

@ -23,6 +23,7 @@ export const zhCN = {
},
shell: {
accountMenu: "{name} 账号菜单",
updateAvailable: "有可用更新",
aria: {
home: "Unsloth 首页",
closeSidebar: "关闭侧边栏",

View file

@ -0,0 +1,25 @@
// 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 { ComponentPropsWithoutRef } from "react";
// Shared lightbulb glyph used by the composer thinking toggle and the
// reasoning "Thinking..." indicator so both stay in sync. Defaults are
// overridable via props.
export const BulbIcon = ({
className,
...props
}: ComponentPropsWithoutRef<"svg">) => (
<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}
{...props}
>
<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>
);

View file

@ -230,12 +230,15 @@ FORCE_COMPILE_DEFAULT_REF = os.environ.get("UNSLOTH_LLAMA_FORCE_COMPILE_REF", "m
_MIN_CUDA_MAJOR = 12
_MAX_PROBE_CUDA_MAJOR = 19
# Blackwell sm_120 capability thresholds. A host is Blackwell when its highest
# compute capability is at least sm_120; ggml compiles sm_120 only at toolkit
# >= 12.8, so an in-release windows-cuda build at or above that already covers
# Blackwell, while cuda-12.4 does not and is dropped on a Blackwell host.
_BLACKWELL_MIN_SM = 120
# Blackwell floor is sm_100: data-center parts (B100/B200 sm_100, B300/GB300
# sm_103) sit below consumer Blackwell (RTX 50 sm_120); the family needs toolkit
# >= 12.8, except sm_103/sm_121 which need 12.9. (120 here wrongly excluded the
# sm_100/103 data-center hosts.)
_BLACKWELL_MIN_SM = 100
_BLACKWELL_MIN_TOOLKIT = (12, 8)
# SMs that need a newer toolkit than the family floor (CUDA 12.9 added native
# sm_103/sm_121 targets; 12.8 covers sm_100/101/120).
_BLACKWELL_SM_MIN_TOOLKIT = {103: (12, 9), 121: (12, 9)}
def _cuda_runtime_lines_for_major(major: int) -> list[str]:
@ -3418,18 +3421,18 @@ def windows_cuda_attempts(
return attempts
def _windows_cuda_attempt_covers_blackwell(attempt: AssetChoice) -> bool:
"""True if an in-release windows-cuda attempt yields a Blackwell sm_120
capable build. The fork's app-named bundles declare their SM coverage
directly; legacy upstream-named bundles instead encode their CUDA toolkit
minor in the filename (covers Blackwell at toolkit >= 12.8)."""
def _windows_cuda_attempt_covers_blackwell(
attempt: AssetChoice, min_toolkit: tuple[int, int] = _BLACKWELL_MIN_TOOLKIT
) -> bool:
"""True if a windows-cuda attempt is Blackwell-capable (app bundles via
declared SMs; legacy upstream bundles via toolkit minor >= min_toolkit:
12.8 for the family, 12.9 for sm_103/sm_121)."""
if attempt.install_kind != "windows-cuda":
return False
# Legacy upstream-named bundles encode their toolkit minor; it is the binding
# constraint (a 12.4 toolkit cannot offload sm_120 whatever its metadata says).
# Legacy bundle: the toolkit minor binds (12.4 cannot offload Blackwell).
m = re.search(r"-bin-win-cuda-(\d+)\.(\d+)-x64\.zip$", attempt.name)
if m is not None:
return (int(m.group(1)), int(m.group(2))) >= _BLACKWELL_MIN_TOOLKIT
return (int(m.group(1)), int(m.group(2))) >= min_toolkit
# App-named bundles carry no minor and declare their SM coverage directly.
return attempt.max_sm is not None and attempt.max_sm >= _BLACKWELL_MIN_SM
@ -3439,22 +3442,30 @@ def _host_is_blackwell(host: HostInfo) -> bool:
return bool(caps) and int(caps[-1]) >= _BLACKWELL_MIN_SM
def _blackwell_min_toolkit_for_host(host: HostInfo) -> tuple[int, int]:
"""Minimum CUDA toolkit this Blackwell host needs: 12.8 for the family,
12.9 if any of its SMs is sm_103/sm_121 (no native target before 12.9)."""
req = _BLACKWELL_MIN_TOOLKIT
for sm in normalize_compute_caps(host.compute_caps):
req = max(req, _BLACKWELL_SM_MIN_TOOLKIT.get(int(sm), _BLACKWELL_MIN_TOOLKIT))
return req
def _drop_blackwell_incapable_windows_cuda(
host: HostInfo, attempts: list[AssetChoice]
) -> list[AssetChoice]:
"""On a Blackwell host, drop windows-cuda attempts that cannot offload
sm_120 (e.g. upstream cuda-12.4, toolkit 12.4). Such a build loads and
passes the functional validator but runs the model on a slow non-native
path (an RTX 5090 measured 7.1 tok/s vs 551.2 on cuda-13.3), so it must
not sit in the fallback chain behind the pin or an in-release cuda13.
Non-cuda attempts (windows-cpu, windows-hip, ...) pass through so the
host still degrades to an honest CPU install when no CUDA 13 exists."""
"""On a Blackwell host, drop windows-cuda attempts that can't offload
Blackwell (e.g. cuda-12.4): they load and validate but run a slow non-native
path (RTX 5090: 7.1 vs 551.2 tok/s on cuda-13.3). Non-cuda attempts pass
through so the host can still fall back to an honest CPU install."""
if not _host_is_blackwell(host):
return attempts
min_toolkit = _blackwell_min_toolkit_for_host(host)
return [
attempt
for attempt in attempts
if attempt.install_kind != "windows-cuda" or _windows_cuda_attempt_covers_blackwell(attempt)
if attempt.install_kind != "windows-cuda"
or _windows_cuda_attempt_covers_blackwell(attempt, min_toolkit)
]

View file

@ -1658,7 +1658,7 @@ def run(
# Packages to skip on Windows (require special build steps)
WINDOWS_SKIP_PACKAGES = {"open_spiel", "triton_kernels"}
WINDOWS_SKIP_PACKAGES = {"triton_kernels"}
# Packages to skip when torch is unavailable (Intel Mac GGUF-only mode).
# These either *are* torch extensions or have unconditional
@ -1675,7 +1675,6 @@ NO_TORCH_SKIP_PACKAGES = {
"torchcodec",
"torch-c-dlpack-ext",
"openai-whisper",
"transformers-cfg",
"librosa",
}
@ -2234,25 +2233,25 @@ def install_python_stack() -> int:
# 4. Overrides (torchao) -- force-reinstall. The torchao version is chosen to
# match the torch installed in the venv so its C++ extensions load (see
# _select_torchao_spec). Skip when torch is unavailable (e.g. Intel Mac
# GGUF-only mode): torchao requires torch.
# GGUF-only mode): torchao requires torch. Also skipped on Windows ROCm
# (no working build; see below).
if NO_TORCH:
_progress("dependency overrides (skipped, no torch)")
elif _rocm_windows_torch_installed:
# No working Windows ROCm torchao build: it imports an absent c10d backend
# and crashes transformers.quantizers. Studio stubs it at runtime, so
# installing it only ships a package that crashes on import -- skip it.
_progress("dependency overrides (skipped, Windows ROCm)")
_safe_print(" Windows ROCm -- skipping torchao (no working build; stubbed at runtime)")
else:
_progress("dependency overrides")
_torch_ver = _probe_installed_torch_version()
_torchao_spec = _select_torchao_spec(_torch_ver)
_safe_print(f" torch {_torch_ver or 'unknown'} detected -- installing {_torchao_spec}")
_override_extra_args: tuple[str, ...] = ()
if _rocm_windows_torch_installed:
# torchao declares torch as a dependency; without --no-deps uv would
# install CPU torch from PyPI, overwriting the AMD ROCm wheels we just
# installed.
_override_extra_args = ("--no-deps",)
pip_install(
"Installing dependency overrides",
"--force-reinstall",
"--no-cache-dir",
*_override_extra_args,
_torchao_spec,
)

View file

@ -3704,35 +3704,49 @@ if (-not $NeedLlamaSourceBuild) {
$CmakeArgs += '-DCMAKE_EXE_LINKER_FLAGS=/NODEFAULTLIB:LIBCMT'
# CUDA flags -- only if GPU available, otherwise explicitly disable
if ($HasNvidiaSmi -and $NvccPath) {
$CmakeArgs += '-DGGML_CUDA=ON'
# Accept a host MSVC newer than nvcc's whitelist; a fresh toolkit
# (e.g. CUDA 13.3) otherwise aborts with "#error -- unsupported
# Microsoft Visual Studio version!". Mirrors the Linux fix. Via env
# (covers the configure probe + build), after Refresh-Environment, idempotent.
$nvccAllowFlag = '-allow-unsupported-compiler'
if ([string]::IsNullOrEmpty($env:NVCC_PREPEND_FLAGS)) {
$env:NVCC_PREPEND_FLAGS = $nvccAllowFlag
} elseif ($env:NVCC_PREPEND_FLAGS -notlike "*$nvccAllowFlag*") {
$env:NVCC_PREPEND_FLAGS = "$($env:NVCC_PREPEND_FLAGS) $nvccAllowFlag"
}
substep "NVCC_PREPEND_FLAGS = $env:NVCC_PREPEND_FLAGS"
$CmakeArgs += "-DCUDAToolkit_ROOT=$CudaToolkitRoot"
$CmakeArgs += "-DCUDA_TOOLKIT_ROOT_DIR=$CudaToolkitRoot"
$CmakeArgs += "-DCMAKE_CUDA_COMPILER=$NvccPath"
if ($CudaArch) {
# Validate nvcc actually supports this architecture
if (Test-NvccArchSupport -NvccExe $NvccPath -Arch $CudaArch) {
$CmakeArgs += "-DCMAKE_CUDA_ARCHITECTURES=$CudaArch"
} else {
# GPU arch too new for this toolkit -- fall back to highest supported.
# PTX forward-compatibility will JIT-compile for the actual GPU at runtime.
$maxArch = Get-NvccMaxArch -NvccExe $NvccPath
if ($maxArch) {
$CmakeArgs += "-DCMAKE_CUDA_ARCHITECTURES=$maxArch"
substep "GPU is sm_$CudaArch but nvcc only supports up to sm_$maxArch" "Yellow"
substep "Building with sm_$maxArch (PTX will JIT for your GPU at runtime)" "Yellow"
# UNSLOTH_LLAMA_CUDA_ARCHS (e.g. "120" or "89;86") forces the build
# arch and wins over detection, matching setup.sh.
$CudaArchOverride = if ($env:UNSLOTH_LLAMA_CUDA_ARCHS) { ($env:UNSLOTH_LLAMA_CUDA_ARCHS -replace '\s', '') } else { '' }
if ((-not $CudaArch) -and (-not $CudaArchOverride)) {
# No detectable compute capability (#5854): -DGGML_CUDA=ON with no
# arch builds a PTX-only binary, so build CPU instead. Mirrors the
# Linux fix; set UNSLOTH_LLAMA_CUDA_ARCHS=120 to force a CUDA build.
substep "could not detect a CUDA compute capability; building CPU llama.cpp instead of a PTX-only binary (set UNSLOTH_LLAMA_CUDA_ARCHS=120 to force a CUDA build)." "Yellow"
$CmakeArgs += '-DGGML_CUDA=OFF'
} else {
$CmakeArgs += '-DGGML_CUDA=ON'
# Accept a host MSVC newer than nvcc's whitelist; a fresh toolkit
# (e.g. CUDA 13.3) otherwise aborts with "#error -- unsupported
# Microsoft Visual Studio version!". Mirrors the Linux fix. Via env
# (covers the configure probe + build), after Refresh-Environment, idempotent.
$nvccAllowFlag = '-allow-unsupported-compiler'
if ([string]::IsNullOrEmpty($env:NVCC_PREPEND_FLAGS)) {
$env:NVCC_PREPEND_FLAGS = $nvccAllowFlag
} elseif ($env:NVCC_PREPEND_FLAGS -notlike "*$nvccAllowFlag*") {
$env:NVCC_PREPEND_FLAGS = "$($env:NVCC_PREPEND_FLAGS) $nvccAllowFlag"
}
substep "NVCC_PREPEND_FLAGS = $env:NVCC_PREPEND_FLAGS"
$CmakeArgs += "-DCUDAToolkit_ROOT=$CudaToolkitRoot"
$CmakeArgs += "-DCUDA_TOOLKIT_ROOT_DIR=$CudaToolkitRoot"
$CmakeArgs += "-DCMAKE_CUDA_COMPILER=$NvccPath"
if ($CudaArchOverride) {
# Forced arch wins verbatim (no nvcc validation), matching setup.sh.
$CmakeArgs += "-DCMAKE_CUDA_ARCHITECTURES=$CudaArchOverride"
} elseif ($CudaArch) {
# Validate nvcc actually supports this architecture
if (Test-NvccArchSupport -NvccExe $NvccPath -Arch $CudaArch) {
$CmakeArgs += "-DCMAKE_CUDA_ARCHITECTURES=$CudaArch"
} else {
# GPU arch too new for this toolkit -- fall back to highest supported.
# PTX forward-compatibility will JIT-compile for the actual GPU at runtime.
$maxArch = Get-NvccMaxArch -NvccExe $NvccPath
if ($maxArch) {
$CmakeArgs += "-DCMAKE_CUDA_ARCHITECTURES=$maxArch"
substep "GPU is sm_$CudaArch but nvcc only supports up to sm_$maxArch" "Yellow"
substep "Building with sm_$maxArch (PTX will JIT for your GPU at runtime)" "Yellow"
}
# else: omit flag entirely, let cmake pick defaults
}
# else: omit flag entirely, let cmake pick defaults
}
}
} else {

View file

@ -155,6 +155,31 @@ _nvcc_meets_llama_minimum() {
echo "$_raw"
}
# Echo a ';'-separated CUDA arch list (e.g. "86;120"). Override ($2,
# UNSLOTH_LLAMA_CUDA_ARCHS) wins verbatim; else parse+dedupe compute_cap text
# ($1). Empty means "no arch detected", so the caller builds CPU instead of a
# PTX-only binary that fails on an old driver (#5854).
_resolve_cuda_archs() {
local _raw_caps=$1
local _arch_override=$2
if [ -n "$_arch_override" ]; then
printf '%s' "$_arch_override"
return 0
fi
local _archs="" _cap _arch
while IFS= read -r _cap; do
_cap=$(printf '%s' "$_cap" | tr -d '[:space:]')
if [[ "$_cap" =~ ^([0-9]+)\.([0-9]+)$ ]]; then
_arch="${BASH_REMATCH[1]}${BASH_REMATCH[2]}"
case ";$_archs;" in
*";$_arch;"*) ;;
*) _archs="${_archs:+$_archs;}$_arch" ;;
esac
fi
done <<< "$_raw_caps"
printf '%s' "$_archs"
}
# Run a GPU probe under a 10s timeout when `timeout` is available so a wedged
# NVIDIA driver cannot hang setup; fall back to a bare call where it is not.
_setup_run_smi() {
@ -1517,35 +1542,38 @@ else
fi
if [ "$_CUDA_TOOLKIT_ALLOWED" = true ]; then
CMAKE_ARGS="$CMAKE_ARGS -DGGML_CUDA=ON"
CUDA_ARCHS=""
if command -v nvidia-smi &>/dev/null; then
_raw_caps=$(_setup_run_smi nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null || true)
while IFS= read -r _cap; do
_cap=$(echo "$_cap" | tr -d '[:space:]')
if [[ "$_cap" =~ ^([0-9]+)\.([0-9]+)$ ]]; then
_arch="${BASH_REMATCH[1]}${BASH_REMATCH[2]}"
case ";$CUDA_ARCHS;" in
*";$_arch;"*) ;;
*) CUDA_ARCHS="${CUDA_ARCHS:+$CUDA_ARCHS;}$_arch" ;;
esac
fi
done <<< "$_raw_caps"
# Resolve the arch list before committing to a CUDA build;
# an empty list means CPU instead of a PTX-only binary (#5854).
_raw_caps=""
# Resolve nvidia-smi as _setup_has_usable_nvidia_gpu does
# (PATH, then /usr/bin); `command -v` alone would miss an
# off-PATH binary and wrongly drop a CUDA host to CPU.
_smi_bin=""
if command -v nvidia-smi >/dev/null 2>&1; then
_smi_bin="nvidia-smi"
elif [ -x "/usr/bin/nvidia-smi" ]; then
_smi_bin="/usr/bin/nvidia-smi"
fi
if [ -n "$_smi_bin" ]; then
_raw_caps=$(_setup_run_smi "$_smi_bin" --query-gpu=compute_cap --format=csv,noheader 2>/dev/null || true)
fi
CUDA_ARCHS="$(_resolve_cuda_archs "$_raw_caps" "${UNSLOTH_LLAMA_CUDA_ARCHS:-}")"
if [ -n "$CUDA_ARCHS" ]; then
CMAKE_ARGS="$CMAKE_ARGS -DCMAKE_CUDA_ARCHITECTURES=${CUDA_ARCHS}"
CMAKE_ARGS="$CMAKE_ARGS -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=${CUDA_ARCHS}"
CMAKE_ARGS="$CMAKE_ARGS -DCMAKE_CUDA_FLAGS=--threads=0"
_BUILD_DESC="building (CUDA, sm_${CUDA_ARCHS//;/+sm_})"
# Allow a host gcc/clang newer than nvcc's whitelist (else a fresh
# toolkit aborts with "unsupported GNU version"); via env to avoid word-splitting.
export NVCC_PREPEND_FLAGS="${NVCC_PREPEND_FLAGS:+$NVCC_PREPEND_FLAGS }-allow-unsupported-compiler"
else
_BUILD_DESC="building (CUDA)"
# No detectable arch: build CPU (CMAKE_ARGS has no
# -DGGML_CUDA=ON yet, so clearing GPU_BACKEND yields CPU).
substep "could not detect a CUDA compute capability; building CPU llama.cpp instead of a PTX-only binary (set UNSLOTH_LLAMA_CUDA_ARCHS, e.g. \"120\", to force a CUDA build)." "$C_WARN"
GPU_BACKEND=""
_BUILD_DESC="building (CPU, CUDA arch undetectable)"
fi
CMAKE_ARGS="$CMAKE_ARGS -DCMAKE_CUDA_FLAGS=--threads=0"
# Allow a host gcc/clang newer than nvcc's whitelist (else a fresh
# toolkit aborts with "unsupported GNU version"); via env to avoid word-splitting.
export NVCC_PREPEND_FLAGS="${NVCC_PREPEND_FLAGS:+$NVCC_PREPEND_FLAGS }-allow-unsupported-compiler"
fi
fi
elif [ "$GPU_BACKEND" = "rocm" ]; then
@ -1794,6 +1822,7 @@ else
printf " ${C_DIM}%-15s${C_OK}%s${C_RST}\n" "launch" "unsloth studio -p 8888"
fi
printf " ${C_DIM}%-15s%s${C_RST}\n" "" "(add -H 0.0.0.0 to allow network / cloud access)"
printf " ${C_DIM}%-15s%s${C_RST}\n" "" "(add --secure to allow HTTPS)"
fi
echo ""

View file

@ -294,6 +294,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@ -518,6 +527,12 @@ dependencies = [
"error-code",
]
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "combine"
version = "4.6.7"
@ -537,6 +552,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "convert_case"
version = "0.4.0"
@ -661,6 +682,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.29.6"
@ -717,6 +747,15 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "ctutils"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
]
[[package]]
name = "darling"
version = "0.23.0"
@ -823,9 +862,20 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
"ctutils",
]
[[package]]
@ -846,7 +896,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -1061,7 +1111,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -1729,11 +1779,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest",
"digest 0.11.3",
]
[[package]]
@ -1806,6 +1856,15 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.8.1"
@ -2274,9 +2333,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.183"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libdbus-sys"
@ -2332,9 +2391,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.29"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "mac"
@ -2435,9 +2494,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.1.1"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
@ -2472,7 +2531,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@ -2902,7 +2961,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.45.0",
]
[[package]]
@ -3621,9 +3680,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.12.3"
version = "1.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
dependencies = [
"aho-corasick",
"memchr",
@ -3644,9 +3703,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.8.10"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
@ -3790,7 +3849,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -3846,7 +3905,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -4078,9 +4137,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.149"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
@ -4111,9 +4170,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "1.0.4"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
@ -4210,7 +4269,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@ -4277,7 +4347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@ -4561,9 +4631,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.11.1"
version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405"
checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28"
dependencies = [
"anyhow",
"bytes",
@ -4648,7 +4718,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"syn 2.0.117",
"tauri-utils",
"thiserror 2.0.18",
@ -4706,9 +4776,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.0"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
@ -4724,9 +4794,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-fs"
version = "2.5.0"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
@ -4742,7 +4812,7 @@ dependencies = [
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"toml 1.1.2+spec-1.1.0",
"url",
]
@ -4767,9 +4837,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-opener"
version = "2.5.3"
version = "2.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f"
checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29"
dependencies = [
"dunce",
"glob",
@ -4799,9 +4869,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.0"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c"
checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
dependencies = [
"serde",
"serde_json",
@ -4981,10 +5051,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -5116,9 +5186,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.50.0"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
@ -5133,9 +5203,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.6.1"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
@ -5195,13 +5265,28 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap 2.13.0",
"serde_core",
"serde_spanned 1.0.4",
"serde_spanned 1.1.1",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap 2.13.0",
"serde_core",
"serde_spanned 1.1.1",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.0",
]
[[package]]
name = "toml_datetime"
version = "0.6.3"
@ -5222,9 +5307,9 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "1.0.1+spec-1.1.0"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
@ -5260,25 +5345,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1"
dependencies = [
"indexmap 2.13.0",
"toml_datetime 1.0.1+spec-1.1.0",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.0",
]
[[package]]
name = "toml_parser"
version = "1.0.10+spec-1.1.0"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow 1.0.0",
]
[[package]]
name = "toml_writer"
version = "1.0.7+spec-1.1.0"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "tower"
@ -5375,7 +5460,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@ -5403,9 +5488,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.19.0"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
@ -5415,7 +5500,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@ -5495,7 +5580,7 @@ dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2",
"sha2 0.11.0",
"simplelog",
"tauri",
"tauri-build",
@ -5509,7 +5594,7 @@ dependencies = [
"tauri-plugin-window-state",
"tokio",
"windows 0.62.2",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -5978,7 +6063,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -6650,7 +6735,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"raw-window-handle",
"sha2",
"sha2 0.10.9",
"soup3",
"tao-macros",
"thiserror 2.0.18",

View file

@ -12,8 +12,8 @@ tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
hmac = "0.12"
sha2 = "0.10"
hmac = "0.13"
sha2 = "0.11"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
log = "0.4"
@ -23,7 +23,7 @@ regex = "1"
open = "5"
process-wrap = { version = "9", features = ["std"] }
fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" }
tauri-plugin-opener = "2.5.3"
tauri-plugin-opener = "2.5.4"
tauri-plugin-updater = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-dialog = "2"
@ -39,7 +39,7 @@ elevated-command = "1.1.2"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62.2", features = ["Win32_System_Threading"] }
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Console", "Win32_System_JobObjects", "Win32_System_Threading"] }
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Console", "Win32_System_JobObjects", "Win32_System_Threading"] }
[build-dependencies]
tauri-build = { version = "2", features = [] }

View file

@ -1,5 +1,5 @@
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use hmac::{Hmac, Mac};
use hmac::{Hmac, KeyInit, Mac};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

View file

@ -0,0 +1,99 @@
"""CPO shares ORPO's row-tokenization replacements (issue #4952).
CPOTrainer reuses ORPO's tokenize/init code, so the ORPO rewriters must also be
registered for cpo_trainer. The rewriters themselves are covered by
test_orpo_processor_text_tokenizer.py; here we just check cpo mirrors orpo.
Static, CPU-only, no torch.
"""
import ast
import os
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
RL_PATH = os.path.join(REPO_ROOT, "unsloth", "models", "rl_replacements.py")
def _registrations(source):
"""Map each RL_FUNCTIONS[key] target to the appended function names."""
out = {}
for node in ast.walk(ast.parse(source)):
if not isinstance(node, ast.Expr):
continue
call = node.value
if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute)):
continue
if call.func.attr != "append":
continue
sub = call.func.value
if not (isinstance(sub, ast.Subscript) and isinstance(sub.value, ast.Name)):
continue
if sub.value.id != "RL_FUNCTIONS":
continue
key = sub.slice
if not (isinstance(key, ast.Constant) and isinstance(key.value, str)):
continue
arg = call.args[0]
if isinstance(arg, ast.Name):
out.setdefault(key.value, []).append(arg.id)
return out
def test_cpo_registration_matches_orpo():
regs = _registrations(open(RL_PATH).read())
shared = {"orpo_trainer_text_tokenizer", "orpo_trainer_processor_pad_token"}
assert shared <= set(regs.get("orpo_trainer", []))
assert shared <= set(regs.get("cpo_trainer", []))
def _load_pad_rewriter():
"""Exec orpo_trainer_processor_pad_token (+ _PAD_FALLBACK) without importing unsloth."""
tree = ast.parse(open(RL_PATH).read())
nodes = []
for n in tree.body:
if isinstance(n, ast.Assign) and any(
getattr(t, "id", None) == "_PAD_FALLBACK" for t in n.targets
):
nodes.append(n)
elif isinstance(n, ast.FunctionDef) and n.name == "orpo_trainer_processor_pad_token":
nodes.append(n)
import re as _re
ns = {"re": _re}
exec(compile(ast.Module(body = nodes, type_ignores = []), RL_PATH, "exec"), ns)
return ns["orpo_trainer_processor_pad_token"]
def test_pad_token_default_routed_through_inner_tokenizer():
# TRL 1.x CPO/ORPO __init__ defaults pad_token from eos_token before
# tokenizing; on a multimodal processor those live on `.tokenizer`. The
# rewrite must route both the default and pad_token_id through the inner
# tokenizer so a processor without bare pad_token does not AttributeError.
rewrite = _load_pad_rewriter()
init_src = (
"def __init__(self, model, args, processing_class):\n"
" if processing_class.pad_token is None:\n"
" processing_class.pad_token = processing_class.eos_token\n"
" self.pad_token_id = processing_class.pad_token_id\n"
)
out = rewrite("__init__", init_src)
assert "if processing_class.pad_token is None:" not in out
assert "processing_class.pad_token = processing_class.eos_token" not in out
assert "_unsloth_proc_tok = getattr(processing_class, 'tokenizer', processing_class)" in out
# bare pad_token_id must be routed through the getattr fallback, not left raw
assert "= processing_class.pad_token_id\n" not in out
ast.parse(out) # rewritten source still compiles
def test_pad_rewrite_noop_without_bare_pad_block():
# Older TRL (the pinned <=0.24.0 range) has no bare pad_token block; the
# rewrite must only touch pad_token_id and leave everything else intact.
rewrite = _load_pad_rewriter()
init_src = (
"def __init__(self, model, args, processing_class):\n"
" self.pad_token_id = processing_class.pad_token_id\n"
)
out = rewrite("__init__", init_src)
assert "_unsloth_proc_tok" not in out
assert "= processing_class.pad_token_id\n" not in out # still routed via fallback
ast.parse(out)

View file

@ -866,13 +866,11 @@ class TestInstallPythonStackFiltering:
result_path = ips._filter_requirements(extras, ips.NO_TORCH_SKIP_PACKAGES)
filtered = Path(result_path).read_text(encoding = "utf-8").lower()
lines = [
l.strip() for l in filtered.splitlines() if l.strip() and not l.strip().startswith("#")
]
for pkg in ["torch-stoi", "timm", "openai-whisper", "transformers-cfg"]:
lines = [
l.strip()
for l in filtered.splitlines()
if l.strip() and not l.strip().startswith("#")
]
for pkg in ips.NO_TORCH_SKIP_PACKAGES:
assert not any(
l.startswith(pkg) for l in lines
), f"{pkg} should be removed from extras.txt"

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