Merge remote-tracking branch 'origin/main' into cuda-torch-index-override

# Conflicts:
#	install.ps1
#	install.sh
#	studio/install_python_stack.py
This commit is contained in:
Daniel Han 2026-07-12 06:18:52 +00:00
commit afe4da63a0
247 changed files with 48195 additions and 5050 deletions

View file

@ -154,7 +154,12 @@ raw_env() { # $1 = var name -> value (one shlex-quote layer stripped)
# writers as a side effect (it writes each agent's relocated session config).
parse_connect() {
local raw="$LOGS_DIR/connect-${AGENT}.txt"
if ! unsloth start "$AGENT" --no-launch --api-key "$UNSLOTH_API_KEY" > "$raw" 2>&1; then
# CONNECT_YOLO=1 adds --yolo. opencode/openclaw gate tool approval through their
# config (which now prompts by default), so the file-edit test opts into auto-approval
# here, the same intent as claude/codex's per-call bypass flags.
local yolo=()
[ -n "${CONNECT_YOLO:-}" ] && yolo=(--yolo)
if ! unsloth start "$AGENT" --no-launch "${yolo[@]}" --api-key "$UNSLOTH_API_KEY" > "$raw" 2>&1; then
cat_redacted "$raw"
guide_fail "'unsloth start ${AGENT} --no-launch' exited non-zero"
fi
@ -371,7 +376,7 @@ case "$MODE" in
hermes) patch_hermes_tools none
invoke_via_connect "$OUT" -z "$PROMPT" ;;
openclaw) patch_openclaw_agent notools
invoke_via_connect "$OUT" agent --local --agent ci \
CONNECT_CMD_OVERRIDE=openclaw invoke_via_connect "$OUT" agent --local --agent ci \
--model "unsloth/${UNSLOTH_MODEL_ID}" --message "$PROMPT" ;;
*) invoke_via_connect "$OUT" "$PROMPT" ;;
esac
@ -394,7 +399,10 @@ case "$MODE" in
T2='Run hello.py with python and show me the exact output.'
# The start.py recipe writers + crosscheck must see the repo; run them
# from the repo root BEFORE cd-ing into the scratch work dir.
# from the repo root BEFORE cd-ing into the scratch work dir. opencode/openclaw
# gate tool approval through their config (prompting by default), so file-edit
# opts them into auto-approval to run edits/commands headlessly.
case "$AGENT" in opencode|openclaw) CONNECT_YOLO=1 ;; esac
parse_connect
crosscheck_contract
# File-edit needs real tools, so we cannot zero them as in connection.
@ -441,7 +449,7 @@ case "$MODE" in
fi ;;
opencode) invoke_via_connect "$out" run "$prompt" ;;
hermes) invoke_via_connect "$out" -z "$prompt" ;;
openclaw) invoke_via_connect "$out" agent --local --agent ci \
openclaw) CONNECT_CMD_OVERRIDE=openclaw invoke_via_connect "$out" agent --local --agent ci \
--model "unsloth/${UNSLOTH_MODEL_ID}" --message "$prompt" ;;
*) invoke_via_connect "$out" "$prompt" ;;
esac
@ -519,6 +527,154 @@ case "$MODE" in
echo "[claude] attribution A/B OK (suppressed HIT, header=1 MISS)"
;;
# ── resume: does a launched agent's session survive exit and resume? ────
# Unlike the other modes, this drives the real LAUNCH path (`unsloth start
# <agent> ...`, the interactive default), not the --no-launch recipe. That
# path relocates each agent's home to a throwaway temp dir wiped on exit, so
# a session cannot be resumed -- unless --persist routes it to the stable
# Unsloth agents dir instead. We run one headless turn per pass and check
# whether the turn left a session in a persistent store (deterministic, no
# reliance on the model recalling anything), for a baseline pass and a
# --persist pass, and assert the expected split for this agent.
resume)
CODEWORD="PLATYPUS7"
T1="Remember this codeword for later: ${CODEWORD}. Reply with just the word OK."
T2="What codeword did I ask you to remember? Reply with just that word."
WORK="$WORKDIR_BASE/${AGENT}-resume"
# STABLE_HOME: the stable dir that --no-launch (and --persist) relocate to.
# Read it from a --no-launch probe (which also writes the agent's config
# there). codex/pi relocate their whole home/HOME here; opencode/claude keep
# their session data in a fixed user dir, so STABLE_HOME stays empty for them.
parse_connect
case "$AGENT" in
codex) STABLE_HOME="$(raw_env CODEX_HOME)" ;;
pi) STABLE_HOME="$(raw_env HOME)" ;;
*) STABLE_HOME="" ;;
esac
# The persistent stores a session would land in if it were NOT wiped. We
# count files here before/after each turn; a positive delta means the
# session persisted (is resumable), zero means it went to a wiped temp dir.
resume_tracked_dirs() {
case "$AGENT" in
codex) printf '%s\n' "$HOME/.codex" ;;
opencode) printf '%s\n' "$HOME/.local/share/opencode" "$HOME/.config/opencode" ;;
claude) printf '%s\n' "$HOME/.claude" ;;
pi) printf '%s\n' "$HOME/.pi" ;;
*) : ;;
esac
[ -n "$STABLE_HOME" ] && printf '%s\n' "$STABLE_HOME"
}
count_session_files() {
local total=0 d n
while IFS= read -r d; do
[ -n "$d" ] && [ -d "$d" ] || continue
n="$(find "$d" -type f 2>/dev/null | wc -l)"; total=$((total + n))
done < <(resume_tracked_dirs)
echo "$total"
}
# The headless first-turn subcommand per agent (mirrors file-edit's map),
# forwarded verbatim through the launch path as passthrough args.
set_t1_cmd() {
case "$AGENT" in
claude) T1_CMD=("${CLAUDE_CONNECT_FLAGS[@]}" -p "$T1") ;;
codex) T1_CMD=(exec "$T1") ;;
opencode) T1_CMD=(run "$T1") ;;
pi) T1_CMD=(-p "$T1") ;;
*) guide_fail "resume mode does not cover agent '$AGENT'" ;;
esac
}
# Run one headless turn through the launch path. $1=outfile, $2="" or
# "--persist", rest = the agent subcommand. --yolo auto-approves so no tool
# prompt can hang; --api-key attaches to the already-served CI model.
launch_turn() {
local out="$1" rflag="$2"; shift 2
local flag=(); [ -n "$rflag" ] && flag=("$rflag")
run_timed "$out" unsloth start "$AGENT" "${flag[@]}" --yolo \
--api-key "$UNSLOTH_API_KEY" "$@"
local rc=$?
redact "$out"
return "$rc"
}
# One pass: fresh work dir, one planting turn, set RESULT to PERSISTED/WIPED
# from the session-store delta. Runs in the main shell (not a command
# substitution) so a hang's guide_fail actually fails the job and the
# progress lines reach the CI log. $1 = "" (baseline) or "--persist".
RESULT=""
run_pass() {
local rflag="$1" label="baseline"
[ -n "$rflag" ] && label="resume"
rm -rf "$WORK"; mkdir -p "$WORK"
set_t1_cmd
local out="$LOGS_DIR/${AGENT}-resume-${label}.txt"
local before after rc
before="$(count_session_files)"
pushd "$WORK" >/dev/null || guide_fail "could not enter work dir $WORK"
launch_turn "$out" "$rflag" "${T1_CMD[@]}"; rc=$?
popd >/dev/null || true
after="$(count_session_files)"
echo "[$AGENT] ${label}: session files ${before} -> ${after} (rc=${rc})"
# The turn must succeed for the delta to mean anything: an agent that writes a
# session file then errors would otherwise be misread as PERSISTED. Mirror the
# file-edit mode and fail the pass on a non-zero launch (the flagship codex recall
# below stays WARN-only, driven by its own launch_turn calls).
[ "$rc" -eq 0 ] || { echo "[$AGENT] ${label} transcript (tail):"; tail -30 "$out" 2>/dev/null || true; \
guide_fail "resume ${label} turn for ${AGENT} exited non-zero (rc=${rc})"; }
if [ "$after" -gt "$before" ]; then RESULT="PERSISTED"; else RESULT="WIPED"; fi
}
run_pass ""; BASELINE="$RESULT"
# Only the temp-dir agents (codex/pi) need the --persist pass to prove the fix.
# opencode/claude persist either way, so the baseline already proves it and a
# second full CPU turn only risks a timeout; skip it for them.
case "$AGENT" in
codex|pi) run_pass "--persist"; RESUME="$RESULT" ;;
*) RESUME="n/a (persists either way)" ;;
esac
# Expected: codex/pi relocate their whole home to the temp dir, so a plain
# launch is WIPED and only --persist PERSISTS. opencode/claude keep their
# session data in a fixed user dir, so the baseline already PERSISTS.
case "$AGENT" in
codex|pi) EXPECT_BASELINE="WIPED" ;;
opencode|claude) EXPECT_BASELINE="PERSISTED" ;;
esac
echo "──────────────────────────────────────────────"
echo "[$AGENT] RESUME EXPERIMENT"
echo " baseline (unsloth start ${AGENT}): ${BASELINE} (expected ${EXPECT_BASELINE})"
echo " with --persist (unsloth start ${AGENT} --persist): ${RESUME}"
echo "──────────────────────────────────────────────"
[ "$BASELINE" = "$EXPECT_BASELINE" ] \
|| guide_fail "baseline resume behavior for ${AGENT} was ${BASELINE}, expected ${EXPECT_BASELINE}"
case "$AGENT" in
codex|pi)
[ "$RESUME" = "PERSISTED" ] \
|| guide_fail "--persist did not persist ${AGENT}'s session (got ${RESUME}); the session dir is still not stable" ;;
esac
# Flagship behavioral proof (codex only, WARN-only): after a --persist plant,
# resume the session and check the model actually recalls the codeword. A
# miss is not a failure (the CI model is small); the mechanism gate above is
# the real assertion.
if [ "$AGENT" = "codex" ]; then
rm -rf "$WORK"; mkdir -p "$WORK"
( cd "$WORK" && launch_turn "$LOGS_DIR/codex-resume-plant.txt" "--persist" exec "$T1" ) || true
( cd "$WORK" && launch_turn "$LOGS_DIR/codex-resume-recall.txt" "--persist" exec resume --last "$T2" ) || true
if grep -q "$CODEWORD" "$LOGS_DIR/codex-resume-recall.txt" 2>/dev/null; then
echo "[codex] behavioral recall HIT: resumed session remembered ${CODEWORD}"
else
echo "::warning::[codex] behavioral recall MISS (small CI model); mechanism gate still passed"
fi
fi
echo "[$AGENT] resume OK"
;;
*)
echo "agent-guides-drive.sh: unknown mode '$MODE'" >&2
exit 2

View file

@ -364,6 +364,9 @@ jobs:
tests/utils/test_attention_masks.py \
tests/utils/test_trunc_normal_patch.py \
tests/python/test_fast_language_model_text_only.py \
tests/test_bad_mappings_redirect.py \
tests/test_prefetch_snapshot_scope.py \
tests/test_gemma_2b_mapper_key.py \
--deselect 'tests/utils/test_attention_masks.py::test_run_attention_flash_varlen_receives_window_and_softcap'
# The deselected test monkeypatches flash_attn_varlen_func, which is
# only bound on the module when `flash_attn` is importable. flash_attn

View file

@ -471,6 +471,176 @@ jobs:
redacted-configs/
retention-days: 7
# ═════════════════════════════════════════════════════════════════════
# Job: resume
# Does a conversation started with `unsloth start <agent>` survive exit
# and resume? This drives the REAL launch path (not the --no-launch
# recipe the other jobs use). A plain launch relocates the agent home to
# a temp dir wiped on exit, so codex/pi cannot resume; --persist routes the
# session to the stable Unsloth agents dir so it persists. opencode/claude
# keep their session data in a fixed user dir, so they persist either way.
# Dispatch-only: it is an end-to-end experiment, not a PR gate.
# ═════════════════════════════════════════════════════════════════════
resume:
name: resume (${{ matrix.agent }})
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
# codex/pi relocate their whole home (resume broken without --persist);
# opencode/claude keep session data in a fixed dir (resume already works).
# One agent from each class proves the split end to end; openclaw/hermes
# share codex's relocation mechanism and are covered by the unit tests.
agent: [codex, opencode, claude, pi]
env:
GGUF_REPO: unsloth/gemma-4-E4B-it-GGUF
GGUF_FILE: gemma-4-E4B-it-UD-Q4_K_XL.gguf
STUDIO_PORT: '18904'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Linux deps for llama.cpp prebuilt
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcurl4-openssl-dev libssl-dev jq
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: 'pip'
- name: Restore GGUF model file
id: cache-gguf
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Download GGUF if cache miss
id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p gguf-cache
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache
- name: Save GGUF model file
if: always() && steps.download-gguf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
mkdir -p logs
set -o pipefail
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
run: |
unsloth studio reset-password
bash .github/scripts/serve-unsloth-run.sh \
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
--port "$STUDIO_PORT" --log-dir logs \
--extra "--seed $UNSLOTH_SEED --temp 0" \
--health-timeout 900
- name: Preflight the agent's API dialect (class-a isolation)
env:
AGENT: ${{ matrix.agent }}
run: |
set -uo pipefail
B="$UNSLOTH_BASE_URL"; K="$UNSLOTH_API_KEY"
preflight_fail() {
echo "::error::[server/API regression] agent=$AGENT: $* (preflight failed BEFORE install/connect). Endpoint contract lives in studio/backend/routes/**.";
exit 1
}
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/models" \
-H "Authorization: Bearer $K") || true
[ "$code" = "200" ] || preflight_fail "/v1/models returned HTTP $code"
case "$AGENT" in
claude)
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/messages" \
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
--max-time 120 \
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
[ "$code" = "200" ] || preflight_fail "/v1/messages returned HTTP $code"
;;
codex)
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/responses" \
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
--max-time 120 \
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"input\":\"Hi\",\"max_output_tokens\":16,\"stream\":true}") || true
[ "$code" = "200" ] || preflight_fail "/v1/responses returned HTTP $code"
;;
*)
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/chat/completions" \
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
--max-time 120 \
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
[ "$code" = "200" ] || preflight_fail "/v1/chat/completions returned HTTP $code"
;;
esac
echo "preflight OK for $AGENT"
- name: Install agent CLI (class-b isolation)
env:
AGENT: ${{ matrix.agent }}
run: bash .github/scripts/agent-guides-install.sh "$AGENT"
- name: Resume experiment (launch path)
env:
AGENT: ${{ matrix.agent }}
run: bash .github/scripts/agent-guides-drive.sh resume "$AGENT"
- name: Collect server logs (debug)
if: always()
run: |
mkdir -p logs/studio-logs
cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true
if [ -n "${UNSLOTH_API_KEY:-}" ]; then
grep -rlF "$UNSLOTH_API_KEY" logs redacted-configs agent-workdir 2>/dev/null | while IFS= read -r f; do
sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
done
fi
- name: Stop Studio
if: always()
run: |
if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true
fi
sleep 2
ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true
- name: Upload logs
if: always()
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: resume-${{ matrix.agent }}-log
path: |
logs/
agent-workdir/
redacted-configs/
retention-days: 7
# ═════════════════════════════════════════════════════════════════════
# Job 3: prompt-cache
# (a) curl 2-turn /v1/chat/completions: assert turn-2 cached_tokens > 0

View file

@ -60,11 +60,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-python@v5
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'

78
.github/workflows/ossf.yml vendored Normal file
View file

@ -0,0 +1,78 @@
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.
name: Scorecard supply-chain security
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
branch_protection_rule:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: '21 20 * * 0'
push:
branches: [ "main" ]
# Declare default permissions as read only.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
# `publish_results: true` only works when run from the default branch. conditional can be removed if disabled.
if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
# Uncomment the permissions below if installing in a private repository.
# contents: read
# actions: read
steps:
- name: "Checkout code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
with:
results_file: results.sarif
results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecard on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: true
# (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore
# file_mode: git
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: SARIF file
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif

View file

@ -2,8 +2,8 @@
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Multi-language supply-chain audit. Triggers:
# - PRs touching any dependency manifest (Python / npm / Cargo) or
# this workflow file,
# - PRs touching any dependency manifest (Python / npm / Cargo), a
# scanner or its allowlist baseline, or this workflow file,
# - push to main / pip,
# - nightly @ 04:13 UTC so newly-published advisories surface even
# when no PR opens,
@ -57,7 +57,9 @@ on:
- 'studio/src-tauri/Cargo.lock'
- 'pyproject.toml'
- 'scripts/scan_packages.py'
- 'scripts/scan_packages_baseline.json'
- 'scripts/scan_npm_packages.py'
- 'scripts/scan_npm_packages_baseline.json'
- '.github/workflows/security-audit.yml'
push:
branches: [main, pip]

View file

@ -444,6 +444,8 @@ jobs:
python - <<'PY'
import json
import os
import time
import urllib.error
import urllib.request
BASE = os.environ["BASE_URL"]
@ -464,8 +466,24 @@ jobs:
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout = timeout) as resp:
return resp.status, json.loads(resp.read().decode())
# Shared CI runners stall sporadically, so retry transport-level
# failures only; HTTP status errors surface immediately. Bounded
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
def post_sse(path, body, *, timeout = 600):
"""POST a streaming request and accumulate the assistant
@ -938,6 +956,8 @@ jobs:
import base64
import json
import os
import time
import urllib.error
import urllib.request
from openai import OpenAI
from anthropic import Anthropic
@ -956,8 +976,24 @@ jobs:
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout = timeout) as resp:
return resp.status, json.loads(resp.read().decode())
# Shared CI runners stall sporadically, so retry transport-level
# failures only; HTTP status errors surface immediately. Bounded
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
# ── 1. response_format = json_object (JSON mode) ─────────────
# llama.cpp's HTTP server supports OpenAI-compatible JSON

View file

@ -430,6 +430,8 @@ jobs:
python - <<'PY'
import json
import os
import time
import urllib.error
import urllib.request
BASE = os.environ["BASE_URL"]
@ -450,8 +452,24 @@ jobs:
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout = timeout) as resp:
return resp.status, json.loads(resp.read().decode())
# Shared CI runners stall sporadically, so retry transport-level
# failures only; HTTP status errors surface immediately. Bounded
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
def post_sse(path, body, *, timeout = 600):
"""POST a streaming request and accumulate the assistant
@ -825,6 +843,8 @@ jobs:
import base64
import json
import os
import time
import urllib.error
import urllib.request
from openai import OpenAI
from anthropic import Anthropic
@ -848,8 +868,24 @@ jobs:
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout = timeout) as resp:
return resp.status, json.loads(resp.read().decode())
# Shared CI runners stall sporadically, so retry transport-level
# failures only; HTTP status errors surface immediately. Bounded
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
# ── 1. response_format = json_object (JSON mode) ─────────────
# llama.cpp's HTTP server supports OpenAI-compatible JSON

View file

@ -634,6 +634,8 @@ jobs:
python - <<'PY'
import json
import os
import time
import urllib.error
import urllib.request
BASE = os.environ["BASE_URL"]
@ -656,8 +658,24 @@ jobs:
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout = timeout) as resp:
return resp.status, json.loads(resp.read().decode())
# Shared CI runners stall sporadically, so retry transport-level
# failures only; HTTP status errors surface immediately. Bounded
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
def post_sse(path, body, *, timeout = 600):
body = {**body, "stream": True}
@ -1063,6 +1081,8 @@ jobs:
import base64
import json
import os
import time
import urllib.error
import urllib.request
from openai import OpenAI
from anthropic import Anthropic
@ -1082,8 +1102,24 @@ jobs:
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout = timeout) as resp:
return resp.status, json.loads(resp.read().decode())
# Shared CI runners stall sporadically, so retry transport-level
# failures only; HTTP status errors surface immediately. Bounded
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
# ── 1. response_format = json_object (JSON mode) ─────────────
status, data = post("/v1/chat/completions", {
@ -1334,42 +1370,75 @@ jobs:
try { Add-MpPreference -ExclusionPath $p -ErrorAction Stop } catch { }
}
- name: Hide Visual Studio + CMake (simulate a host with no build tools)
- name: Prepare no-build-tools simulation
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# A Program Files dir can hold a transient handle (Defender / MSBuild node)
# so Rename-Item intermittently fails with "Access is denied"; retry to ride it out.
function Rename-WithRetry($Path, $NewName) {
for ($i = 1; $i -le 6; $i++) {
try { Rename-Item -LiteralPath $Path -NewName $NewName -ErrorAction Stop; return }
catch { if ($i -eq 6) { throw }; Start-Sleep -Seconds 3 }
$root = Join-Path $env:GITHUB_WORKSPACE 'no-build-tools'
$pf = Join-Path $root 'ProgramFiles'
$pfx86 = Join-Path $root 'ProgramFilesx86'
New-Item -ItemType Directory -Force -Path $pf, $pfx86 | Out-Null
$blocked = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($tool in @('cmake', 'cl.exe')) {
foreach ($cmd in (Get-Command $tool -All -ErrorAction SilentlyContinue)) {
if ($cmd.Source) {
$dir = Split-Path -Parent $cmd.Source
if ($dir) {
[void] $blocked.Add(
[Environment]::ExpandEnvironmentVariables($dir).Trim().Trim('"').TrimEnd('\'))
}
}
}
}
# Rename the Visual Studio install roots (incl. the Installer that holds
# vswhere.exe) so Find-VsBuildTools' vswhere + filesystem scan both miss.
foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
if (Test-Path -LiteralPath $d) {
Rename-WithRetry $d ((Split-Path $d -Leaf) + '.vsoff')
Write-Host "Hid VS: $d"
}
# Normalized comparison so registry spellings (trailing slash,
# unexpanded %VAR%) still match.
function Test-Blocked([string]$p) {
$n = [Environment]::ExpandEnvironmentVariables($p).Trim().Trim('"').TrimEnd('\')
return $blocked.Contains($n)
}
# Surgically rename each cmake executable on PATH (not its parent dir --
# cmake can share a dir with other shims) so Get-Command cmake fails.
$hidden = @()
foreach ($c in (Get-Command cmake -All -ErrorAction SilentlyContinue)) {
if ($c.Source -and (Test-Path -LiteralPath $c.Source)) {
Rename-WithRetry $c.Source ((Split-Path $c.Source -Leaf) + '.off')
$hidden += $c.Source
Write-Host "Hid cmake: $($c.Source)"
}
$pathParts = $env:Path -split [IO.Path]::PathSeparator |
Where-Object { $_ -and -not (Test-Blocked $_) }
$noBuildToolsPath = $pathParts -join [IO.Path]::PathSeparator
# install.ps1's Refresh-SessionPath and setup.ps1's Refresh-Environment
# rebuild the session Path from these scopes mid-install, so filter
# them too. Originals are saved for the cleanup step.
foreach ($scope in @('Machine', 'User')) {
$orig = [Environment]::GetEnvironmentVariable('Path', $scope)
if (-not $orig) { continue }
Set-Content -LiteralPath (Join-Path $root "orig-path-$scope.txt") -Value $orig -NoNewline
$kept = ($orig -split ';' | Where-Object { $_ -and -not (Test-Blocked $_) }) -join ';'
[Environment]::SetEnvironmentVariable('Path', $kept, $scope)
Write-Host "Filtered $scope Path scope."
}
"NO_BUILD_TOOLS_PROGRAMFILES=$pf" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"NO_BUILD_TOOLS_PROGRAMFILES_X86=$pfx86" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"NO_BUILD_TOOLS_PATH<<NO_BUILD_TOOLS_PATH_EOF" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
$noBuildToolsPath | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"NO_BUILD_TOOLS_PATH_EOF" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Host "ProgramFiles simulation root: $pf"
Write-Host "ProgramFiles(x86) simulation root: $pfx86"
if ($blocked.Count -gt 0) {
Write-Host "Removed build-tool PATH dirs:"
$blocked | Sort-Object | ForEach-Object { Write-Host " $_" }
} else {
Write-Host "No cmake or cl.exe PATH dirs found to remove."
}
("HIDDEN_CMAKE=" + ($hidden -join '|')) | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Assert Visual Studio + CMake are genuinely undetectable
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# Set in-script: the runner does not apply step-level env keys with
# parentheses (`ProgramFiles(x86)`), so vswhere still found VS.
if (-not $env:NO_BUILD_TOOLS_PROGRAMFILES) { Write-Error "NO_BUILD_TOOLS_* env missing (Prepare step did not run?)"; exit 1 }
$env:ProgramFiles = $env:NO_BUILD_TOOLS_PROGRAMFILES
${env:ProgramFiles(x86)} = $env:NO_BUILD_TOOLS_PROGRAMFILES_X86
$env:Path = $env:NO_BUILD_TOOLS_PATH
. (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1')
$setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1'
foreach ($fn in @('Resolve-VsGeneratorFromLabel', 'Find-VsBuildTools')) {
@ -1394,6 +1463,10 @@ jobs:
# 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 in-script (see the assert step); child processes inherit these.
$env:ProgramFiles = $env:NO_BUILD_TOOLS_PROGRAMFILES
${env:ProgramFiles(x86)} = $env:NO_BUILD_TOOLS_PROGRAMFILES_X86
$env:Path = $env:NO_BUILD_TOOLS_PATH
New-Item -ItemType Directory -Force -Path logs | Out-Null
$ProgressPreference = 'SilentlyContinue'
& ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log
@ -1480,19 +1553,19 @@ jobs:
[ -n "$CONTENT" ] && [ "$CONTENT" != "null" ] || { echo "::error::empty completion"; exit 1; }
echo "Inference OK without Visual Studio: $CONTENT"
- name: Restore Visual Studio + CMake
- name: Clean no-build-tools simulation
if: always()
shell: pwsh
run: |
foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
$off = "$d.vsoff"
if (Test-Path -LiteralPath $off) { Rename-Item -LiteralPath $off -NewName (Split-Path $d -Leaf); Write-Host "Restored $d" }
}
if ($env:HIDDEN_CMAKE) {
foreach ($src in ($env:HIDDEN_CMAKE -split '\|')) {
if ($src -and (Test-Path -LiteralPath "$src.off")) { Rename-Item -LiteralPath "$src.off" -NewName (Split-Path $src -Leaf) }
$root = Join-Path $env:GITHUB_WORKSPACE 'no-build-tools'
foreach ($scope in @('Machine', 'User')) {
$saved = Join-Path $root "orig-path-$scope.txt"
if (Test-Path -LiteralPath $saved) {
[Environment]::SetEnvironmentVariable('Path', (Get-Content -LiteralPath $saved -Raw), $scope)
Write-Host "Restored $scope Path scope."
}
}
Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
- name: Stop Studio
if: always()
@ -1540,21 +1613,34 @@ jobs:
with:
python-version: '3.12'
- name: Hide Visual Studio
- name: Prepare no-build-tools simulation
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# Retry the rename: a Program Files dir can hold a transient handle that
# makes Rename-Item intermittently fail with "Access is denied".
function Rename-WithRetry($Path, $NewName) {
for ($i = 1; $i -le 6; $i++) {
try { Rename-Item -LiteralPath $Path -NewName $NewName -ErrorAction Stop; return }
catch { if ($i -eq 6) { throw }; Start-Sleep -Seconds 3 }
$root = Join-Path $env:GITHUB_WORKSPACE 'no-build-tools'
$pf = Join-Path $root 'ProgramFiles'
$pfx86 = Join-Path $root 'ProgramFilesx86'
New-Item -ItemType Directory -Force -Path $pf, $pfx86 | Out-Null
$blocked = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($tool in @('cmake', 'cl.exe')) {
foreach ($cmd in (Get-Command $tool -All -ErrorAction SilentlyContinue)) {
if ($cmd.Source) {
$dir = Split-Path -Parent $cmd.Source
if ($dir) { [void] $blocked.Add($dir) }
}
}
}
foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
if (Test-Path -LiteralPath $d) { Rename-WithRetry $d ((Split-Path $d -Leaf) + '.vsoff'); Write-Host "Hid VS: $d" }
}
$pathParts = $env:Path -split [IO.Path]::PathSeparator |
Where-Object { $_ -and -not $blocked.Contains($_) }
$noBuildToolsPath = $pathParts -join [IO.Path]::PathSeparator
"NO_BUILD_TOOLS_PROGRAMFILES=$pf" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"NO_BUILD_TOOLS_PROGRAMFILES_X86=$pfx86" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"NO_BUILD_TOOLS_PATH<<NO_BUILD_TOOLS_PATH_EOF" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
$noBuildToolsPath | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"NO_BUILD_TOOLS_PATH_EOF" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Windows CUDA and ROCm prebuilts exist in unslothai/llama.cpp (what GPU users download, no VS)
env:
@ -1577,25 +1663,34 @@ jobs:
echo "Windows CUDA and ROCm prebuilts are available -- GPU users get them without compiling."
- name: The prebuilt resolver runs without Visual Studio
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$ErrorActionPreference = 'Stop'
# pwsh: bash cannot export `ProgramFiles(x86)`; set in-script so the
# python child inherits the overrides.
$env:ProgramFiles = $env:NO_BUILD_TOOLS_PROGRAMFILES
${env:ProgramFiles(x86)} = $env:NO_BUILD_TOOLS_PROGRAMFILES_X86
$env:Path = $env:NO_BUILD_TOOLS_PATH
# Resolver-only (no GPU on hosted runners, so the host resolves to the
# CPU bundle). The point is that resolution needs no compiler/VS.
python -m pip install --upgrade huggingface_hub
python studio/install_llama_prebuilt.py --resolve-prebuilt latest --output-format json > /tmp/resolve.json || {
echo "::error::resolver exited non-zero"; cat /tmp/resolve.json || true; exit 1; }
cat /tmp/resolve.json
echo "Prebuilt resolver ran with no Visual Studio present."
if ($LASTEXITCODE -ne 0) { Write-Host "::error::pip install huggingface_hub failed"; exit 1 }
python studio/install_llama_prebuilt.py --resolve-prebuilt latest --output-format json > resolve.json
if ($LASTEXITCODE -ne 0) {
Write-Host "::error::resolver exited non-zero"
if (Test-Path resolve.json) { Get-Content resolve.json }
exit 1
}
Get-Content resolve.json
Write-Host "Prebuilt resolver ran with no Visual Studio present."
- name: Restore Visual Studio
- name: Clean no-build-tools simulation
if: always()
shell: pwsh
run: |
foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
$off = "$d.vsoff"
if (Test-Path -LiteralPath $off) { Rename-Item -LiteralPath $off -NewName (Split-Path $d -Leaf); Write-Host "Restored $d" }
}
Remove-Item -LiteralPath (Join-Path $env:GITHUB_WORKSPACE 'no-build-tools') -Recurse -Force -ErrorAction SilentlyContinue
# ── folded from studio-setup-ps1-vs2026.yml: setup.ps1 unit tests + real-VS detection + vcredist ──
pester:
@ -1610,6 +1705,13 @@ jobs:
- name: Install Pester v5
shell: pwsh
run: |
# PSGallery is intermittently absent from the repository list on GitHub's Windows
# runners, which makes `Set-PSRepository PSGallery` fail with "No repository with the
# name 'PSGallery' was found." Re-register the default gallery first so the policy
# change and module install below always have a repository to target.
if (-not (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSRepository -Default -ErrorAction SilentlyContinue
}
Set-PSRepository PSGallery -InstallationPolicy Trusted
Install-Module Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck -Scope CurrentUser
Import-Module Pester -MinimumVersion 5.5.0

View file

@ -6,9 +6,9 @@
# windows-latest runner:
#
# 1. install.ps1 --local --no-torch installs Studio AND auto-fetches
# the prebuilt llama.cpp Windows binary (llama-bNNNN-bin-win-cpu-
# x64 from ggml-org/llama.cpp). Hitting the source-build fallback
# is treated as an Unsloth bug -- Studio must always pick the
# the prebuilt llama.cpp Windows binary (app-<tag>-windows-x64-cpu
# from unslothai/llama.cpp). Hitting the source-build fallback is
# treated as an Unsloth bug -- Studio must always pick the
# prebuilt on Windows.
# 2. unsloth studio update --local is idempotent. Two consecutive
# runs both report "prebuilt up to date and validated", no

View file

@ -285,6 +285,92 @@ jobs:
tests/vllm_compat/test_extended_module_imports.py \
-v --tb=short
# Fake-CUDA GRPO/SFT/DPO patch run against REAL TRL (latest + main). Unlike
# the static symbol/source greps above, this drives unsloth's actual
# source-transform patchers (models/rl.py + rl_replacements.py) on a CPU-only
# runner under the tests/conftest.py spoof harness -- no GPU, no training.
# Catches structural TRL drift the greps miss (e.g. TRL 1.7.0's 2->3-tuple
# per-token-logps return, restructured PEFT ref-adapter block) by asserting
# the generated Unsloth trainer still satisfies the transform contracts.
grpo-fake-run:
name: GRPO fake-run (latest + main TRL, CPU spoof)
runs-on: ubuntu-latest
timeout-minutes: 18
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
path: unsloth
- name: Clone unsloth-zoo @ main
run: |
for attempt in 1 2 3; do
rm -rf "$RUNNER_TEMP/unsloth-zoo"
if git clone --depth=1 https://github.com/unslothai/unsloth-zoo \
"$RUNNER_TEMP/unsloth-zoo"; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "::error::git clone unsloth-zoo failed after 3 attempts"
exit 1
fi
delay=$((5 * attempt))
echo "::warning::clone failed (attempt $attempt/3), retrying in ${delay}s..."
sleep "$delay"
done
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: 'pip'
- name: Install CPU torch + ecosystem + TRL latest
run: |
python -m pip install --upgrade pip
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \
'torch>=2.4,<2.11' 'torchvision<0.26' 'torchcodec<0.10'
# Ecosystem floors unsloth needs; TRL itself is installed last so it
# can pull the transformers/peft it requires.
pip install \
'transformers>=4.57' 'peft>=0.18.0' 'accelerate>=1.0' 'datasets>=3.4,<5' \
'bitsandbytes>=0.45.5' sentencepiece protobuf safetensors numpy 'pytest>=8' \
'huggingface_hub>=0.34' tqdm packaging psutil triton Pillow
pip install --upgrade trl
pip install --no-deps -e "$RUNNER_TEMP/unsloth-zoo"
pip install --no-deps -e ./unsloth
- name: Fake-run vs TRL latest
env:
UNSLOTH_IS_PRESENT: '1'
UNSLOTH_COMPILE_DISABLE: '1'
# Disable dynamo/inductor at the process level, before conftest.py's early
# `import unsloth`, so the GRPO hot path never compiles on the GPU-less runner
# (defense in depth; the CPU fake-train also flips this at runtime).
TORCHDYNAMO_DISABLE: '1'
TORCH_COMPILE_DISABLE: '1'
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
run: |
cd unsloth
python -c "import trl; print('Resolved TRL', trl.__version__)"
PYTHONPATH=. python -m pytest \
tests/version_compat/test_trl_grpo_fake_run.py \
tests/version_compat/test_trl_fake_train_cpu.py \
-v --tb=short
# `main` is scheduled/dispatch-only so PR jobs stay fast and a bleeding-edge
# TRL break does not red every PR. github.event_name is valid in a step if.
- name: Fake-run vs TRL main (scheduled / dispatch only)
if: ${{ github.event_name != 'pull_request' }}
env:
UNSLOTH_IS_PRESENT: '1'
UNSLOTH_COMPILE_DISABLE: '1'
TORCHDYNAMO_DISABLE: '1'
TORCH_COMPILE_DISABLE: '1'
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
run: |
pip install --upgrade "git+https://github.com/huggingface/trl"
cd unsloth
python -c "import trl; print('Resolved TRL', trl.__version__)"
PYTHONPATH=. python -m pytest \
tests/version_compat/test_trl_grpo_fake_run.py \
tests/version_compat/test_trl_fake_train_cpu.py \
-v --tb=short
# Daily-only: same suites but with --strict on importable upstream
# tags. Schedule-only so PR jobs stay fast; cron tolerates a flake.
daily-fresh-fetch:

2
.gitignore vendored
View file

@ -11,6 +11,8 @@ outputs/
exports/
/datasets/
studio/backend/assets/datasets/
# Generated async worker / reviewer transcripts (never part of the product).
studio/backend/async_task_outputs/
unsloth_training_checkpoints/
*.gguf
*.safetensors

View file

@ -212,7 +212,7 @@ By default `unsloth studio` binds to `127.0.0.1` (this machine only). To reach i
```bash
unsloth studio --secure -p 8888
```
- `-H 0.0.0.0`: bind the raw port on all network interfaces, reachable from anywhere on the network. Only use this on a trusted network.
- `-H 0.0.0.0`: bind the raw port on all network interfaces, reachable from anywhere on the network. This also starts a public Cloudflare quick tunnel by default, which publishes an internet-reachable `https://*.trycloudflare.com` URL even behind a firewall. Both the raw port and the tunnel expose Studio beyond this machine, so only use this on a network you trust; pass `--no-cloudflare` to drop the public link while keeping the network bind.
```bash
unsloth studio -H 0.0.0.0 -p 8888
```

View file

@ -473,6 +473,17 @@ function Install-UnslothStudio {
param(
[Parameter(Mandatory = $true)][ScriptBlock]$Command
)
# Installer-pinned index installs (torch) must beat an inherited uv mirror
# (#6898): when the command pins an index, clear every uv index env var so
# it wins, then restore in finally. Other installs keep the user's mirror.
$savedUvIndex = $null
if ($Command.ToString() -match '--default-index') {
$savedUvIndex = @{}
foreach ($n in 'UV_DEFAULT_INDEX', 'UV_INDEX_URL', 'UV_INDEX', 'UV_EXTRA_INDEX_URL') {
$savedUvIndex[$n] = [Environment]::GetEnvironmentVariable($n)
Remove-Item "Env:$n" -ErrorAction SilentlyContinue
}
}
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
@ -492,6 +503,7 @@ function Install-UnslothStudio {
return [int]$LASTEXITCODE
} finally {
$ErrorActionPreference = $prevEap
if ($savedUvIndex) { foreach ($n in $savedUvIndex.Keys) { if ($null -ne $savedUvIndex[$n]) { Set-Item "Env:$n" $savedUvIndex[$n] } } }
}
}
@ -2254,7 +2266,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.9" "unsloth-zoo>=2026.6.7" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" }
if ($baseInstallExit -eq 0) {
# Resolve pydantic WITH deps so pip pins pydantic-core
# to the matching version (no-torch-runtime.txt below
@ -2268,7 +2280,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.9" "unsloth-zoo>=2026.6.7" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" }
}
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
@ -2299,7 +2311,7 @@ exit 0
# ABI-incompatible torchvision/torchaudio on AMD's per-arch index.
$visionSpec = if ($PinnedRocmVisionSpec) { $PinnedRocmVisionSpec } elseif ($ROCmGfxArch -and $torchvisionFloorMap -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
$audioSpec = if ($PinnedRocmAudioSpec) { $PinnedRocmAudioSpec } elseif ($ROCmGfxArch -and $torchaudioFloorMap -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" }
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (AMD ROCm)" { uv pip install --python $VenvPython --force-reinstall --index-url $ROCmIndexUrl $torchSpec $visionSpec $audioSpec }
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (AMD ROCm)" { uv pip install --python $VenvPython --force-reinstall --default-index $ROCmIndexUrl $torchSpec $visionSpec $audioSpec }
if ($torchInstallExit -ne 0) {
# Transient AMD-index failure: fall back to a CPU base so the install
# still completes; Studio setup retries ROCm afterwards.
@ -2312,7 +2324,7 @@ exit 0
# torch (e.g. 2.10.0+rocm on gfx110X/gfx90a) that still satisfies the CPU
# torch>= range, so without it uv would keep the ROCm build and only swap
# the companions -- a mismatched venv the flavor-repair block won't fix.
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (CPU fallback)" { uv pip install --python $VenvPython --force-reinstall "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $CpuFallbackIndexUrl }
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (CPU fallback)" { uv pip install --python $VenvPython --force-reinstall "torch>=2.4,<2.11.0" torchvision torchaudio --default-index $CpuFallbackIndexUrl }
if ($torchInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install PyTorch (ROCm and CPU base both failed, exit code $torchInstallExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
@ -2329,7 +2341,7 @@ exit 0
} else {
Write-TauriLog "STEP" "Installing PyTorch"
substep "installing PyTorch ($TorchIndexUrl)..."
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl }
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --default-index $TorchIndexUrl }
if ($torchInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install PyTorch (exit code $torchInstallExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
@ -2341,7 +2353,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.9" "unsloth-zoo>=2026.6.7" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" }
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 }
@ -2353,7 +2365,7 @@ exit 0
}
}
} elseif ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" }
} else {
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth" { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
}
@ -2381,7 +2393,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.7" "unsloth>=2026.6.9" --torch-backend=auto }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.7.2" "unsloth>=2026.7.2" --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)
@ -2412,7 +2424,7 @@ exit 0
# keeps a stale torch==X+cpu against a CUDA index and setup.ps1 then loops on
# "torch cpu != required cuXXX". Reinstall the right triplet when a GPU build is
# expected: CUDA from $TorchIndexUrl, ROCm from $ROCmIndexUrl (repo.amd.com gfx*
# is a PEP 503 index uv resolves via --index-url, same URL the fresh ROCm install
# is a PEP 503 index uv resolves via --default-index, same URL the fresh ROCm install
# above uses). --no-torch / CPU-only hosts (expected cpu) are no-ops.
if (-not $SkipTorch) {
$expectedTorchTag = Get-ExpectedTorchFlavorTag -TorchIndexUrl $TorchIndexUrl -ROCmIndexUrl $ROCmIndexUrl
@ -2428,7 +2440,7 @@ exit 0
$visionSpec = if ($PinnedRocmVisionSpec) { $PinnedRocmVisionSpec } elseif ($ROCmGfxArch -and $torchvisionFloorMap -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
$audioSpec = if ($PinnedRocmAudioSpec) { $PinnedRocmAudioSpec } elseif ($ROCmGfxArch -and $torchaudioFloorMap -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" }
substep "PyTorch flavor mismatch (installed $installedTorchTag, need ROCm) -- reinstalling correct build..." "Yellow"
$torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython --force-reinstall --index-url $ROCmIndexUrl $rocmSpec $visionSpec $audioSpec }
$torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython --force-reinstall --default-index $ROCmIndexUrl $rocmSpec $visionSpec $audioSpec }
if ($torchFixExit -ne 0) {
Write-Host "[ERROR] Failed to reinstall PyTorch with the correct ROCm build (exit code $torchFixExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to reinstall PyTorch (ROCm) (exit code $torchFixExit)" $torchFixExit)
@ -2440,7 +2452,7 @@ exit 0
} elseif ($expectedTorchTag -ne 'rocm') {
# CUDA: stale +cpu (or wrong cuXXX) against a CUDA index -> reinstall triplet.
substep "PyTorch flavor mismatch (installed $installedTorchTag, need $expectedTorchTag) -- reinstalling correct build..." "Yellow"
$torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio }
$torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --default-index $TorchIndexUrl --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio }
if ($torchFixExit -ne 0) {
Write-Host "[ERROR] Failed to reinstall PyTorch with the correct CUDA build (exit code $torchFixExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to reinstall PyTorch ($expectedTorchTag) (exit code $torchFixExit)" $torchFixExit)

View file

@ -159,6 +159,12 @@ run_maybe_quiet() {
run_install_cmd() {
_label="$1"
shift
# Installer-pinned index installs (torch) must beat an inherited uv mirror
# (#6898): when we pass --default-index, neutralize every uv index env var so
# the pinned index wins. Other installs keep the user's mirror.
case " $* " in
*" --default-index "*) set -- env -u UV_DEFAULT_INDEX -u UV_INDEX_URL -u UV_INDEX -u UV_EXTRA_INDEX_URL "$@" ;;
esac
if _is_verbose; then
"$@" && return 0
_rc=$?
@ -1446,8 +1452,14 @@ if [ "$_NO_TORCH_FLAG" = true ] || [ "$MAC_INTEL" = true ]; then
SKIP_TORCH=true
fi
# Apple Silicon: exclude broken mlx-lm 0.31.3 (QK-norm load regression for
# gemma4 / qwen3_5; mlx-lm #1242). A curl-piped install has no overrides file
# and skips the guarded MLX step (SKIP_STUDIO_BASE=1), so this is the only cover.
_MLX_LM_EXCLUDE_ARG=""
# Apple Silicon: override mlx-vlm / mlx-lm's transformers pin (see overrides file).
if [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
_MLX_LM_EXCLUDE_ARG="mlx-lm!=0.31.3"
_OVERRIDES_FILE="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)/studio/backend/requirements/single-env/overrides-darwin-arm64.txt"
if [ -f "$_OVERRIDES_FILE" ]; then
# uv splits UV_OVERRIDE on whitespace, so a repo path with whitespace
@ -1481,6 +1493,81 @@ elif [ "$OS" = "macos" ]; then
fi
tauri_diag_marker "$_TAURI_INITIAL_GPU_BRANCH" "none"
# AMD GPU name from the Windows host via WMI, or empty. Discrete cards aren't in
# /proc/cpuinfo, so ask Windows. Cached ("-" = negative), self-contained, bounded
# to 10s. Defined here so the reroute below can use it before _run_bounded exists.
_WSL_AMD_GPU_NAME_CACHE=""
_wsl_amd_gpu_name() {
if [ -n "$_WSL_AMD_GPU_NAME_CACHE" ]; then
[ "$_WSL_AMD_GPU_NAME_CACHE" = "-" ] && return 1
printf '%s' "$_WSL_AMD_GPU_NAME_CACHE"; return 0
fi
command -v powershell.exe >/dev/null 2>&1 || { _WSL_AMD_GPU_NAME_CACHE="-"; return 1; }
_wag_ps="(Get-CimInstance Win32_VideoController | Where-Object { \$_.Name -match 'AMD|Radeon' } | Select-Object -First 1).Name"
if command -v timeout >/dev/null 2>&1; then
_wag_n="$(timeout 10 powershell.exe -NoProfile -Command "$_wag_ps" 2>/dev/null | tr -d '\r\n\000')"
else
_wag_n="$(powershell.exe -NoProfile -Command "$_wag_ps" 2>/dev/null | tr -d '\r\n\000')"
fi
if [ -n "$_wag_n" ]; then _WSL_AMD_GPU_NAME_CACHE="$_wag_n"; printf '%s' "$_wag_n"; return 0; fi
_WSL_AMD_GPU_NAME_CACHE="-"; return 1
}
# ── Bounded command runner ──
# Runs a command under a 10s timeout when the `timeout` binary is available,
# otherwise runs it unbounded. Keeps a wedged nvidia-smi (blocking during
# driver init or after a reset) from hanging the installer: a timed-out probe
# exits nonzero and is treated exactly like a failed probe. No-op semantics on
# hosts without `timeout` (e.g. macOS) or when the probe is healthy.
_run_bounded() {
if command -v timeout >/dev/null 2>&1; then
timeout 10 "$@"
else
"$@"
fi
}
# Returns 0 (true) when CUDA_VISIBLE_DEVICES is set to "" or "-1", i.e. every
# NVIDIA device is deliberately hidden (mixed AMD+NVIDIA hosts steering work to
# the AMD card). Unset means all devices visible. nvidia-smi ignores this env
# var, so the probes below cannot see the distinction on their own.
_cvd_hides_nvidia() {
[ "${CUDA_VISIBLE_DEVICES+set}" = "set" ] || return 1
_cvd_trim=$(printf '%s' "$CUDA_VISIBLE_DEVICES" | tr -d '[:space:]')
[ -z "$_cvd_trim" ] || [ "$_cvd_trim" = "-1" ]
}
# ── NVIDIA usable-GPU helper ──
# Returns 0 (true) if an NVIDIA GPU is present and usable.
# Primary probe: nvidia-smi -L. Fallback: /proc/driver/nvidia/gpus/ sysfs,
# which the NVIDIA driver populates on Linux regardless of nvidia-smi state
# -- handles PATH gaps, subprocess timeouts, and driver init races that
# could otherwise cause nvidia-smi to fail and silence NVIDIA detection.
# A GPU hidden via CUDA_VISIBLE_DEVICES=""/-1 counts as NOT usable (matches
# install_llama_prebuilt.py has_usable_nvidia), so AMD/CPU routing still runs.
_has_usable_nvidia_gpu() {
if _cvd_hides_nvidia; then
return 1
fi
_nvsmi=""
if command -v nvidia-smi >/dev/null 2>&1; then
_nvsmi="nvidia-smi"
elif [ -x "/usr/bin/nvidia-smi" ]; then
_nvsmi="/usr/bin/nvidia-smi"
fi
if [ -n "$_nvsmi" ]; then
if _run_bounded "$_nvsmi" -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}'; then
return 0
fi
fi
# Fallback: NVIDIA driver exposes one subdir per GPU under this path.
if [ -d /proc/driver/nvidia/gpus ] && \
[ -n "$(ls -A /proc/driver/nvidia/gpus 2>/dev/null)" ]; then
return 0
fi
return 1
}
# Strix Halo ROCm-on-WSL only targets Ubuntu 24.04. On a newer distro (e.g. 26.04)
# with a 24.04 distro present, re-run the install there and stop; else fall through
# to CPU + the `wsl --install` hint below (never auto-create a distro). Runs before
@ -1491,7 +1578,15 @@ _maybe_reroute_strixhalo_to_2404() {
[ "${UNSLOTH_SKIP_ROCM_WSL_SETUP:-0}" = "1" ] && return 0
[ "${UNSLOTH_WSL_REROUTED:-0}" = "1" ] && return 0
[ -e /dev/dxg ] || return 0
grep -qiE 'Ryzen AI Max|Radeon 80[0-9]0S|Strix Halo' /proc/cpuinfo 2>/dev/null || return 0
# A usable NVIDIA GPU (common on hybrid AMD+NVIDIA hosts) means the CUDA path works on
# this distro, so don't reroute for AMD. _has_usable_nvidia_gpu (moved above) honors
# CUDA_VISIBLE_DEVICES=""/-1 and the /proc/driver/nvidia fallback for PATH/timeout gaps.
if _has_usable_nvidia_gpu; then return 0; fi
# Strix APUs show in /proc/cpuinfo; discrete cards don't, so also try WMI. Either reroutes.
if ! grep -qiE 'Ryzen AI Max|Radeon 80[0-9]0S|Strix Halo' /proc/cpuinfo 2>/dev/null \
&& ! _wsl_amd_gpu_name >/dev/null 2>&1; then
return 0
fi
# Already ROCm-on-WSL? leave a working GPU alone, whatever the version.
if [ -e /opt/rocm/lib/librocdxg.so ] || [ -e /opt/rocm/lib64/librocdxg.so ]; then
return 0
@ -1965,61 +2060,6 @@ _has_amd_rocm_gpu() {
return 1
}
# ── Bounded command runner ──
# Runs a command under a 10s timeout when the `timeout` binary is available,
# otherwise runs it unbounded. Keeps a wedged nvidia-smi (blocking during
# driver init or after a reset) from hanging the installer: a timed-out probe
# exits nonzero and is treated exactly like a failed probe. No-op semantics on
# hosts without `timeout` (e.g. macOS) or when the probe is healthy.
_run_bounded() {
if command -v timeout >/dev/null 2>&1; then
timeout 10 "$@"
else
"$@"
fi
}
# Returns 0 (true) when CUDA_VISIBLE_DEVICES is set to "" or "-1", i.e. every
# NVIDIA device is deliberately hidden (mixed AMD+NVIDIA hosts steering work to
# the AMD card). Unset means all devices visible. nvidia-smi ignores this env
# var, so the probes below cannot see the distinction on their own.
_cvd_hides_nvidia() {
[ "${CUDA_VISIBLE_DEVICES+set}" = "set" ] || return 1
_cvd_trim=$(printf '%s' "$CUDA_VISIBLE_DEVICES" | tr -d '[:space:]')
[ -z "$_cvd_trim" ] || [ "$_cvd_trim" = "-1" ]
}
# ── NVIDIA usable-GPU helper ──
# Returns 0 (true) if an NVIDIA GPU is present and usable.
# Primary probe: nvidia-smi -L. Fallback: /proc/driver/nvidia/gpus/ sysfs,
# which the NVIDIA driver populates on Linux regardless of nvidia-smi state
# -- handles PATH gaps, subprocess timeouts, and driver init races that
# could otherwise cause nvidia-smi to fail and silence NVIDIA detection.
# A GPU hidden via CUDA_VISIBLE_DEVICES=""/-1 counts as NOT usable (matches
# install_llama_prebuilt.py has_usable_nvidia), so AMD/CPU routing still runs.
_has_usable_nvidia_gpu() {
if _cvd_hides_nvidia; then
return 1
fi
_nvsmi=""
if command -v nvidia-smi >/dev/null 2>&1; then
_nvsmi="nvidia-smi"
elif [ -x "/usr/bin/nvidia-smi" ]; then
_nvsmi="/usr/bin/nvidia-smi"
fi
if [ -n "$_nvsmi" ]; then
if _run_bounded "$_nvsmi" -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}'; then
return 0
fi
fi
# Fallback: NVIDIA driver exposes one subdir per GPU under this path.
if [ -d /proc/driver/nvidia/gpus ] && \
[ -n "$(ls -A /proc/driver/nvidia/gpus 2>/dev/null)" ]; then
return 0
fi
return 1
}
# ── Detect GPU and choose PyTorch index URL ──
# Mirrors Get-TorchIndexUrl in install.ps1.
# On CPU-only machines this returns the cpu index, avoiding the solver
@ -2199,9 +2239,9 @@ _expected_torch_flavor_tag() {
esac
}
# Whether index ($1) supports a plain --index-url reinstall. pytorch.org cuXXX /
# Whether index ($1) supports a plain --default-index reinstall. pytorch.org cuXXX /
# rocmX.Y AND the repo.amd.com gfx* indexes are all PEP 503 simple indexes that uv
# resolves (torch + every transitive dep) via --index-url -- the same URLs the
# resolves (torch + every transitive dep) via --default-index -- the same URLs the
# fresh-install paths above already use -- so a stale wheel is auto-repairable.
# Unknown/odd-mirror leaves -> no, so we warn rather than risk a wrong reinstall.
# Lowercase the leaf first so a cased leaf (e.g. gfx120X-all) is recognised.
@ -2432,19 +2472,19 @@ _persist_rocm_wsl_dropin() {
fi
}
# _wsl_amd_gpu_name is defined earlier so both the reroute and this bootstrap can use it.
_maybe_bootstrap_rocm_wsl() {
[ "${OS:-}" = "wsl" ] || return 0
[ "${SKIP_TORCH:-false}" = "false" ] || return 0
[ "${UNSLOTH_SKIP_ROCM_WSL_SETUP:-0}" = "1" ] && return 0
# Leave any already-usable GPU completely alone (NVIDIA, or working ROCm).
if _has_usable_nvidia_gpu; then return 0; fi
# "Usable ROCm" here = rocminfo enumerates the gfx1151 agent. Don't use the
# generic _has_amd_rocm_gpu: its broad gfx match accepts "gfx11-generic" and
# would skip this bootstrap while the real GPU is still unusable. awk consumes
# all input, so rocminfo isn't SIGPIPE'd like `grep -q` would under pipefail.
# Usable ROCm = rocminfo enumerates a real GPU agent: gfx[1-9] (excludes gfx000,
# the CPU agent) and not the "gfx11-generic" fallback. awk consumes all input so
# rocminfo isn't SIGPIPE'd like `grep -q` under pipefail.
_ensure_rocm_probe_env
if command -v rocminfo >/dev/null 2>&1 && \
rocminfo 2>/dev/null | awk '/Name:[[:space:]]*gfx1151/{found=1} END{exit !found}'; then
rocminfo 2>/dev/null | awk '/Name:[[:space:]]*gfx[1-9]/ && !/generic/{found=1} END{exit !found}'; then
# rocminfo may work only via the transient env _ensure_rocm_probe_env
# just set, which dies with the installer. Persist the drop-in so login
# shells (Studio, llama.cpp) inherit it -- else a reinstall over an
@ -2454,9 +2494,12 @@ _maybe_bootstrap_rocm_wsl() {
fi
# WSL GPU passthrough device must exist (present on any WSL2 GPU host).
[ -e /dev/dxg ] || return 0
# Only Strix Halo (gfx1151): rocminfo can't tell us the arch yet, so match
# the CPU model string WSL exposes (e.g. "AMD Ryzen AI Max+ ... Radeon 8060S").
grep -qiE 'Ryzen AI Max|Radeon 80[0-9]0S|Strix Halo' /proc/cpuinfo 2>/dev/null || return 0
# Strix APUs show in /proc/cpuinfo (the CPU model); discrete cards don't, so also
# ask the Windows host. Either signal suffices; the bootstrap detects arch from rocminfo.
if ! grep -qiE 'Ryzen AI Max|Radeon 80[0-9]0S|Strix Halo' /proc/cpuinfo 2>/dev/null \
&& ! _wsl_amd_gpu_name >/dev/null 2>&1; then
return 0
fi
command -v bash >/dev/null 2>&1 || return 0
# Fast path: already configured (librocdxg present) but launched from a
@ -2474,7 +2517,8 @@ _maybe_bootstrap_rocm_wsl() {
fi
echo ""
substep "Detected AMD Strix Halo (Radeon 8000S) in WSL with no ROCm runtime yet." "$C_WARN"
_rw_gpu="$(_wsl_amd_gpu_name 2>/dev/null || true)"; [ -n "$_rw_gpu" ] || _rw_gpu="an AMD GPU"
substep "Detected ${_rw_gpu} in WSL with no ROCm runtime yet." "$C_WARN"
substep "Setting up ROCm-on-WSL (ROCm 7.2 + librocdxg) automatically to enable this GPU."
substep "One-time, uses sudo and a large download. (skip: re-run with UNSLOTH_SKIP_ROCM_WSL_SETUP=1)"
@ -2852,7 +2896,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.9" "unsloth-zoo>=2026.6.7"
"unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2"
# 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.
@ -2863,9 +2907,11 @@ if [ "$_MIGRATED" = true ]; then
run_install_cmd_retry "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
fi
else
# Pin mlx-lm away from 0.31.3 here too: a curl-piped migration has no
# overrides file, so UV_OVERRIDE is unset and this positional is the only cover.
run_install_cmd_retry "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7"
"unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" ${_MLX_LM_EXCLUDE_ARG:-}
fi
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
substep "overlaying local repo (editable)..."
@ -2888,7 +2934,7 @@ if [ "$_MIGRATED" = true ]; then
substep "repairing ROCm torch (overwritten by dependency resolution)..."
run_install_cmd_retry "repair ROCm torch" uv pip install --python "$_VENV_PY" \
"$TORCH_CONSTRAINT" "$TORCHVISION_CONSTRAINT" "$TORCHAUDIO_CONSTRAINT" \
--index-url "$TORCH_INDEX_URL" \
--default-index "$TORCH_INDEX_URL" \
--force-reinstall
# torch was actually reinstalled from $TORCH_INDEX_URL now, so the
# marker should record it (the preserved-torch case above must not).
@ -3017,7 +3063,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
substep "[WARN] Radeon repo lacks a compatible wheel set for this Python; falling back to ROCm index ($TORCH_INDEX_URL)" "$C_WARN"
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" \
"$TORCH_CONSTRAINT" "$TORCHVISION_CONSTRAINT" "$TORCHAUDIO_CONSTRAINT" \
--index-url "$TORCH_INDEX_URL"
--default-index "$TORCH_INDEX_URL"
else
substep "installing PyTorch from Radeon repo (${_RADEON_BASE_URL})..."
# Record the ACTUAL wheel source for the torch-index marker: this
@ -3049,18 +3095,18 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
substep "[WARN] Radeon repo unavailable; falling back to ROCm index ($TORCH_INDEX_URL)" "$C_WARN"
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" \
"$TORCH_CONSTRAINT" "$TORCHVISION_CONSTRAINT" "$TORCHAUDIO_CONSTRAINT" \
--index-url "$TORCH_INDEX_URL"
--default-index "$TORCH_INDEX_URL"
fi
else
substep "[WARN] Radeon GPU detected but could not detect full ROCm version; falling back to ROCm index" "$C_WARN"
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" \
"$TORCH_CONSTRAINT" "$TORCHVISION_CONSTRAINT" "$TORCHAUDIO_CONSTRAINT" \
--index-url "$TORCH_INDEX_URL"
--default-index "$TORCH_INDEX_URL"
fi
else
substep "installing PyTorch ($TORCH_INDEX_URL)..."
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" "$TORCH_CONSTRAINT" "$TORCHVISION_CONSTRAINT" "$TORCHAUDIO_CONSTRAINT" \
--index-url "$TORCH_INDEX_URL"
--default-index "$TORCH_INDEX_URL"
fi
# AMD ROCm: install bitsandbytes (once, after torch, for all ROCm paths).
# Gate on SKIP_TORCH=false so a user running with --no-torch on a ROCm
@ -3081,7 +3127,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.9" "unsloth-zoo>=2026.6.7"
"unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2"
# 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
@ -3099,7 +3145,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.9" "unsloth-zoo>=2026.6.7"
--upgrade-package unsloth "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2"
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..."
@ -3108,7 +3154,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
"unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo"
else
run_install_cmd_retry "install unsloth" uv pip install --python "$_VENV_PY" \
--upgrade-package unsloth -- "$PACKAGE_NAME"
--upgrade-package unsloth -- "$PACKAGE_NAME" ${_MLX_LM_EXCLUDE_ARG:-}
fi
# AMD ROCm: repair torch if the unsloth/unsloth-zoo install pulled in
# CUDA torch from PyPI, overwriting the ROCm wheels installed in Step 1.
@ -3120,7 +3166,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
substep "repairing ROCm torch (overwritten by dependency resolution)..."
run_install_cmd_retry "repair ROCm torch" uv pip install --python "$_VENV_PY" \
"$TORCH_CONSTRAINT" "$TORCHVISION_CONSTRAINT" "$TORCHAUDIO_CONSTRAINT" \
--index-url "$TORCH_INDEX_URL" \
--default-index "$TORCH_INDEX_URL" \
--force-reinstall
# The repair reinstalled torch from $TORCH_INDEX_URL (the generic
# ROCm index), not the Radeon --find-links repo, so record THAT as
@ -3138,7 +3184,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.7" "unsloth>=2026.6.9" --torch-backend=auto
run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.7.2" "unsloth>=2026.7.2" --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..."
@ -3162,14 +3208,14 @@ if [ "$SKIP_TORCH" = false ] && [ -n "${TORCH_INDEX_URL:-}" ]; then
_installed_torch_ver=$("$_VENV_PY" -c "import torch; print(torch.__version__)" 2>/dev/null || true)
_installed_torch_tag=""
[ -n "$_installed_torch_ver" ] && _installed_torch_tag=$(_torch_flavor_tag "$_installed_torch_ver")
# Repair when flavor is wrong AND the index is plain --index-url reinstallable
# Repair when flavor is wrong AND the index is plain --default-index reinstallable
# (cuXXX / rocmX.Y / repo.amd.com gfx*); an unknown mirror leaf -> warn only.
if [ -n "$_installed_torch_tag" ] && [ "$_installed_torch_tag" != "$_expected_torch_tag" ] \
&& [ "$(_torch_index_repairable "$TORCH_INDEX_URL")" = "yes" ]; then
substep "PyTorch flavor mismatch (installed $_installed_torch_tag, need $_expected_torch_tag) -- reinstalling correct build..."
run_install_cmd "reinstall PyTorch ($_expected_torch_tag)" uv pip install --python "$_VENV_PY" \
"$TORCH_CONSTRAINT" "$TORCHVISION_CONSTRAINT" "$TORCHAUDIO_CONSTRAINT" \
--index-url "$TORCH_INDEX_URL" \
--default-index "$TORCH_INDEX_URL" \
--reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio
# torch was re-landed from $TORCH_INDEX_URL, so record it even on a
# migrated venv whose flavor was genuinely wrong (the gfx-switch case
@ -3184,7 +3230,7 @@ if [ "$SKIP_TORCH" = false ] && [ -n "${TORCH_INDEX_URL:-}" ]; then
substep "[WARN] PyTorch is CPU-only but a $_expected_torch_tag GPU build was expected for this machine." "$C_WARN"
substep "[WARN] Training and GPU inference will run on CPU until this is fixed." "$C_WARN"
substep "[WARN] Re-run this installer, or reinstall the GPU build manually:" "$C_WARN"
substep "[WARN] uv pip install --python \"$_VENV_PY\" \"$TORCH_CONSTRAINT\" \"$TORCHVISION_CONSTRAINT\" \"$TORCHAUDIO_CONSTRAINT\" --index-url $TORCH_INDEX_URL --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio" "$C_WARN"
substep "[WARN] uv pip install --python \"$_VENV_PY\" \"$TORCH_CONSTRAINT\" \"$TORCHVISION_CONSTRAINT\" \"$TORCHAUDIO_CONSTRAINT\" --default-index $TORCH_INDEX_URL --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio" "$C_WARN"
fi
fi
fi

View file

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

View file

@ -3,13 +3,14 @@
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# ──────────────────────────────────────────────────────────────────────────────
# Enable ROCm-on-WSL for AMD Strix Halo (Radeon 8060S / gfx1151)
# Enable ROCm-on-WSL for AMD GPUs (Strix Halo/Point APUs AND discrete Radeon RX
# 7000/9000). Verified on gfx1151 (Radeon 8060S) and gfx1200 (Radeon RX 9060 XT).
# ──────────────────────────────────────────────────────────────────────────────
# install.sh already routes gfx1151 to the right ROCm wheels once a ROCm runtime
# is present; what it does NOT do is install AMD's ROCm userspace + the WSL DXG
# bridge. This helper automates that Linux-side prerequisite on Ubuntu 24.04
# WSL2 and is invoked by install.sh when it sees a Strix Halo APU in WSL (via
# /dev/dxg) but no ROCm runtime yet. Fully idempotent (re-run just re-verifies).
# install.sh routes the detected arch to the right ROCm wheels once a runtime exists;
# what it does NOT do is install AMD's ROCm userspace + the WSL DXG bridge (librocdxg).
# This helper does that Linux-side prerequisite on Ubuntu 24.04 WSL2, invoked by
# install.sh when it sees an AMD GPU via /dev/dxg but no ROCm yet. Arch-agnostic: the
# arch is auto-detected from rocminfo (override UNSLOTH_WSL_GFX=gfx1200). Idempotent.
#
# Manual, admin-gated Windows prerequisite: an AMD Adrenalin driver with
# production ROCDXG/WSL support (26.2.2+). install.ps1 offers to update it. Once
@ -34,10 +35,12 @@ set -euo pipefail
# ── Tunables (override via env) ──────────────────────────────────────────────
ROCM_VER="${UNSLOTH_WSL_ROCM_VER:-7.2.1}" # ROCm release to install
GFX="gfx1151"
# GPU arch: empty = auto-detect from rocminfo after install (override UNSLOTH_WSL_GFX=gfx1200).
# The ROCm + librocdxg setup is arch-agnostic; only verify + the smoke test need the arch.
GFX="${UNSLOTH_WSL_GFX:-}"
LIBROCDXG_REF="${UNSLOTH_LIBROCDXG_REF:-develop}" # ROCm/librocdxg git ref to build
# AMD's gfx1151 wheel index (same one install.sh uses); only for the smoke test.
TORCH_INDEX="${UNSLOTH_AMD_ROCM_MIRROR:-https://repo.amd.com/rocm/whl}/${GFX}/"
# AMD's wheel index for the (optional) smoke test; resolved after arch detection.
TORCH_INDEX=""
# Optional torch smoke test (throwaway venv). OFF by default: install.sh installs
# torch itself into the real venv right after, so a duplicate download is wasteful.
SMOKE_TEST="${UNSLOTH_WSL_SMOKE_TEST:-0}"
@ -220,12 +223,12 @@ $SUDO ldconfig
say "Persisting ROCm-on-WSL environment"
_envfile="/etc/profile.d/unsloth-rocm-wsl.sh"
$SUDO tee "$_envfile" >/dev/null <<EOF
# >>> Unsloth ROCm-on-WSL (gfx1151) >>>
# >>> Unsloth ROCm-on-WSL >>>
export HSA_ENABLE_DXG_DETECTION=1
export TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1
export PATH="${ROCM_DIR}/bin:\${PATH}"
export LD_LIBRARY_PATH="${ROCM_DIR}/lib:\${LD_LIBRARY_PATH:-}"
# <<< Unsloth ROCm-on-WSL (gfx1151) <<<
# <<< Unsloth ROCm-on-WSL <<<
EOF
# also drop into ~/.bashrc for interactive shells
if [ -n "${HOME:-}" ] && ! grep -q "Unsloth ROCm-on-WSL" "${HOME}/.bashrc" 2>/dev/null; then
@ -237,32 +240,50 @@ export PATH="${ROCM_DIR}/bin:${PATH}"
export LD_LIBRARY_PATH="${ROCM_DIR}/lib:${LD_LIBRARY_PATH:-}"
# ── Step 5: verify the runtime enumerates the GPU ────────────────────────────
say "Verifying rocminfo sees ${GFX}"
say "Verifying rocminfo enumerates the GPU over DXG"
# Capture rocminfo into a var BEFORE grepping: piping into `grep -q` SIGPIPEs
# rocminfo on first match, which under `set -o pipefail` turns a successful match
# into a pipeline failure. Match the gfx1151 ISA "Name:" agent exactly (not a
# broad gfx1[0-9]) so a generic fallback ISA or unrelated RDNA GPU can't pass.
# into a pipeline failure.
_rocminfo_out="$(rocminfo 2>/dev/null || true)"
if ! printf '%s\n' "$_rocminfo_out" | grep -qE "Name:[[:space:]]*${GFX}([^0-9]|$)"; then
# GPU agents advertise an ISA "Name: gfxNNNN". Match gfx[1-9] (excludes gfx000, the CPU
# agent), drop the "gfx*-generic" fallback ISA, and take the first real GPU arch.
_detected_gfx="$(printf '%s\n' "$_rocminfo_out" | grep -E 'Name:[[:space:]]*gfx[1-9]' | grep -v 'generic' | grep -oE 'gfx[1-9][0-9a-z]*' | head -1 || true)"
if [ -z "$_detected_gfx" ]; then
printf '%s\n' "$_rocminfo_out" | head -25 >&2 || true
die "rocminfo did not enumerate a ${GFX} GPU agent. Most common cause: the Windows AMD driver predates production ROCDXG -- update Adrenalin (install.ps1 offers this), reboot, and re-run."
die "rocminfo did not enumerate any GPU agent. Most common cause: the Windows AMD driver predates production ROCDXG -- update Adrenalin (install.ps1 offers this), reboot, and re-run."
fi
# Honour a caller-pinned arch (sanity-check via a consuming grep, not grep -q: under
# pipefail -q would SIGPIPE printf on large output and misreport the arch); else adopt.
if [ -n "$GFX" ] && ! printf '%s\n' "$_rocminfo_out" | grep -E "Name:[[:space:]]*${GFX}([^0-9]|$)" >/dev/null; then
die "rocminfo enumerated '${_detected_gfx}' but not the requested UNSLOTH_WSL_GFX='${GFX}'."
fi
GFX="${GFX:-$_detected_gfx}"
# Display-only summary: best-effort (|| true) so head's early pipe-close under
# `set -o pipefail` can't fail the bootstrap after verification already passed.
printf '%s\n' "$_rocminfo_out" | grep -E 'Marketing Name|Device Type|Compute Unit' | grep -iE "Radeon|GPU|Compute" | head -3 || true
note "ROCm-on-WSL runtime is live for ${GFX}."
# ── Step 6 (optional): torch smoke test from the gfx1151 index ───────────────
# ── Step 6 (optional): torch smoke test from AMD's per-arch wheel index ───────
if [ "$SMOKE_TEST" = "1" ]; then
say "Smoke-testing PyTorch on ${GFX} (throwaway venv)"
# Map the detected arch to AMD's repo.amd.com wheel family index.
case "$GFX" in
gfx1200|gfx1201) _fam="gfx120X-all" ;;
gfx1100|gfx1101|gfx1102|gfx1103) _fam="gfx110X-all" ;;
*) _fam="$GFX" ;; # gfx1150/gfx1151/gfx90a: own index
esac
TORCH_INDEX="${UNSLOTH_AMD_ROCM_MIRROR:-https://repo.amd.com/rocm/whl}/${_fam}/"
_venv="${HOME}/.unsloth/rocm-smoketest"
rm -rf "$_venv"; python3 -m venv "$_venv"
"$_venv/bin/pip" install --quiet --upgrade pip
# gfx1151 index is primary (torch + triton); PyPI only an extra for pure-py
# AMD arch index is primary (torch + triton); PyPI only an extra for pure-py
# deps. The constraint keeps pip on the ROCm wheel, not a newer PyPI CUDA torch.
"$_venv/bin/pip" install --index-url "$TORCH_INDEX" \
--extra-index-url https://pypi.org/simple "$TORCH_CONSTRAINT" || \
die "torch install from ${TORCH_INDEX} failed."
# WSL: torch's bundled ROCr must load the DXG bridge -- drop librocdxg into torch/lib.
_tlib="$("$_venv/bin/python" -c 'import torch,os;print(os.path.join(os.path.dirname(torch.__file__),"lib"))' 2>/dev/null || true)"
[ -d "$_tlib" ] && cp -f "${ROCM_DIR}"/lib/librocdxg.so* "$_tlib"/ 2>/dev/null || true
"$_venv/bin/python" - <<'PY'
import torch
ok = torch.cuda.is_available()

File diff suppressed because one or more lines are too long

View file

@ -564,6 +564,12 @@ def compare(before_src: str, after_src: str, path: str) -> list[tuple[str, str]]
for n, tids in b["module_import_targets"].items():
if tids & after_used:
continue # resolved -> fine
# `from __future__ import ...` is a compiler directive, not a runtime
# binding: the name (`annotations`, ...) is never loaded, so it can never
# "resolve" to a use. Skip it so a legitimately-added future import
# (e.g. `annotations` for lazy PEP 604 `X | None` on py3.9) is not flagged.
if all(t.startswith("from:__future__:") for t in tids):
continue
newly_added = bool(tids - before_module_targets)
was_used_before = bool(tids & before_used)
if newly_added or was_used_before:
@ -588,9 +594,23 @@ def compare(before_src: str, after_src: str, path: str) -> list[tuple[str, str]]
# 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.
#
# A deliberate *relocation* is also benign and must not block: when a name
# keeps its spelling but its import source is moved A -> B in THIS diff (the
# old `from A import x` is removed at module level and a new `from B import x`
# is added), the swap is intentional, not a silent re-point to a pre-existing
# different object. This mirrors the relocation tolerance already applied to
# TARGET-MISSING. The dangerous case -- the name now resolving to a target
# that already existed before (shadow/clash) -- is NOT exempted.
removed_module_targets = before_module_targets - after_module_targets
for key, tafter in b["target_by_use"].items():
tbefore = a["target_by_use"].get(key)
if tbefore and tbefore != tafter and (tbefore - tafter):
lost = tbefore - tafter
gained = tafter - tbefore
relocated = lost <= removed_module_targets and gained <= added_module_targets
if relocated:
continue
findings.append(
(
"BLOCKER",

View file

@ -235,6 +235,13 @@
"min_p": 0.1,
"repetition_penalty": 1.0
},
"deepseek-v4": {
"temperature": 1.0,
"top_p": 1.0,
"top_k": -1,
"min_p": 0.0,
"repetition_penalty": 1.0
},
"deepseek-r1": {
"temperature": 0.6,
"top_p": 0.95,
@ -394,7 +401,7 @@
"phi-4", "phi-3",
"mistral-nemo", "mistral-small", "mistral-large", "magistral", "ministral",
"devstral", "pixtral",
"deepseek-r1", "deepseek-v3", "deepseek-ocr",
"deepseek-v4", "deepseek-r1", "deepseek-v3", "deepseek-ocr",
"glm-5", "glm-4",
"nemotron",
"minimax-m2.7", "minimax-m2.5", "minimax",

View file

@ -7,13 +7,16 @@ Inference submodule - backend for model loading and generation.
The default get_inference_backend() returns an InferenceOrchestrator that
delegates to a subprocess. The original InferenceBackend runs inside the
subprocess and can be imported directly from .inference when needed.
Public names are resolved lazily (PEP 562): importing this package -- or a
dependency-light leaf like ``core.inference.chat_eos`` -- must NOT eagerly pull
the orchestrator / llama_cpp import chain (httpx, subprocess plumbing, the ML
backend and its Studio dependencies). Those load only when a public name is
actually accessed, so standalone helpers stay unit-testable without the full
inference stack.
"""
from .orchestrator import InferenceOrchestrator, get_inference_backend
from .llama_cpp import LlamaCppBackend
# Expose InferenceOrchestrator as InferenceBackend for backward compat.
InferenceBackend = InferenceOrchestrator
from typing import TYPE_CHECKING
__all__ = [
"InferenceBackend",
@ -21,3 +24,33 @@ __all__ = [
"get_inference_backend",
"LlamaCppBackend",
]
# name -> (submodule, attribute); InferenceBackend aliases InferenceOrchestrator.
_LAZY_ATTRS = {
"InferenceOrchestrator": ("orchestrator", "InferenceOrchestrator"),
"InferenceBackend": ("orchestrator", "InferenceOrchestrator"),
"get_inference_backend": ("orchestrator", "get_inference_backend"),
"LlamaCppBackend": ("llama_cpp", "LlamaCppBackend"),
}
def __getattr__(name):
try:
submodule, attr = _LAZY_ATTRS[name]
except KeyError:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
from importlib import import_module
value = getattr(import_module(f"{__name__}.{submodule}"), attr)
globals()[name] = value # cache so later access skips __getattr__
return value
def __dir__():
return sorted(set(globals()) | set(__all__))
if TYPE_CHECKING: # keep static analysers / IDEs aware of the lazy names
from .llama_cpp import LlamaCppBackend
from .orchestrator import InferenceOrchestrator, get_inference_backend
InferenceBackend = InferenceOrchestrator

View file

@ -0,0 +1,110 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Standalone free-VRAM probe for the bundled ggml Vulkan backend.
Run in a short-lived subprocess (``python _vulkan_probe.py <bindir>``) so the
Vulkan instance never lives in the long-running backend process. Loads the
bundled ggml Vulkan backend from ``<bindir>`` and prints one
``<idx>\\t<free_bytes>\\t<is_igpu>\\t<total_bytes>`` line per device to stdout.
Indices are ggml's own Vulkan device ordinals, which need not match nvidia-smi
order. ``is_igpu`` (from ggml's device type) is ``1`` for an integrated GPU
sharing system RAM. ``total_bytes`` is the device-local heap; the reader uses
it to reserve absolute headroom on a discrete card (parity with the CUDA/ROCm
fit) and ignores it for an iGPU, whose "VRAM" is shared system RAM.
Uses only the standard library so it stays runnable as a bare script.
"""
import ctypes
import os
import sys
# ggml_backend_dev_type enum (ggml-backend.h): CPU=0, GPU=1, IGPU=2, ...
_GGML_BACKEND_DEVICE_TYPE_IGPU = 2
def _igpu_flags(base, lib, count: int) -> list[bool]:
"""Per-device integrated-GPU flags via ggml's backend registry.
The Vulkan reg enumerates devices in the same order as
``ggml_backend_vk_get_device_memory`` (each context uses ``ctx->device =
i``), so reg index == device ordinal. Returns all-False on any failure so
the reader never over-caps a discrete card.
"""
flags = [False] * count
try:
lib.ggml_backend_vk_reg.restype = ctypes.c_void_p
lib.ggml_backend_vk_reg.argtypes = []
base.ggml_backend_reg_dev_count.restype = ctypes.c_size_t
base.ggml_backend_reg_dev_count.argtypes = [ctypes.c_void_p]
base.ggml_backend_reg_dev_get.restype = ctypes.c_void_p
base.ggml_backend_reg_dev_get.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
base.ggml_backend_dev_type.restype = ctypes.c_int
base.ggml_backend_dev_type.argtypes = [ctypes.c_void_p]
reg = lib.ggml_backend_vk_reg()
if not reg:
return flags
dev_count = base.ggml_backend_reg_dev_count(reg)
for i in range(min(count, dev_count)):
dev = base.ggml_backend_reg_dev_get(reg, i)
if dev:
flags[i] = base.ggml_backend_dev_type(dev) == _GGML_BACKEND_DEVICE_TYPE_IGPU
except Exception:
# Best-effort: any failure degrades to "discrete" so the memory
# readings still get through instead of crashing the probe.
pass
return flags
def main() -> int:
if len(sys.argv) < 2:
return 0
bindir = sys.argv[1]
# Hold add_dll_directory's handle for the rest of main() (the documented
# idiom) so bindir stays on the search path while the sibling ggml DLLs
# resolve below.
_dll_dir = None
if sys.platform == "win32":
base_name, vk_name = "ggml-base.dll", "ggml-vulkan.dll"
try:
_dll_dir = os.add_dll_directory(bindir)
except Exception:
pass
else:
base_name, vk_name = "libggml-base.so", "libggml-vulkan.so"
# RTLD_GLOBAL exposes ggml-base's symbols to ggml-vulkan on POSIX. getattr
# falls back to 0 where the flag doesn't exist (Windows CDLL ignores mode).
_rtld_global = getattr(ctypes, "RTLD_GLOBAL", 0)
try:
base = ctypes.CDLL(os.path.join(bindir, base_name), mode = _rtld_global)
lib = ctypes.CDLL(os.path.join(bindir, vk_name), mode = _rtld_global)
except OSError as e:
print(f"ggml-vulkan load failed: {e}", file = sys.stderr)
return 1
lib.ggml_backend_vk_get_device_count.restype = ctypes.c_int
lib.ggml_backend_vk_get_device_count.argtypes = []
lib.ggml_backend_vk_get_device_memory.restype = None
lib.ggml_backend_vk_get_device_memory.argtypes = [
ctypes.c_int,
ctypes.POINTER(ctypes.c_size_t),
ctypes.POINTER(ctypes.c_size_t),
]
count = lib.ggml_backend_vk_get_device_count()
igpu = _igpu_flags(base, lib, count)
rows = []
for i in range(count):
free, total = ctypes.c_size_t(0), ctypes.c_size_t(0)
lib.ggml_backend_vk_get_device_memory(i, ctypes.byref(free), ctypes.byref(total))
rows.append("%d\t%d\t%d\t%d" % (i, free.value, int(igpu[i]), total.value))
sys.stdout.write("\n".join(rows))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -258,6 +258,10 @@ class AnthropicStreamEmitter:
self._open_tool_use_id: Optional[str] = None
self._open_tool_args_sent: bool = False
self._prev_text: str = ""
# Net <think> minus </think> in the text emitted to the client. Tracked
# from emitted deltas (not _prev_text, which a final bare shrink clobbers)
# so an unclosed reasoning-only block can be balanced before close.
self._open_think_tags: int = 0
self._usage: dict = {}
def start(
@ -317,6 +321,7 @@ class AnthropicStreamEmitter:
"""Close any open block and emit message_delta + message_stop."""
events = []
if self._text_block_open or self._open_tool_call_id is not None:
events.extend(self._close_open_think())
events.append(self._close_block())
self._open_tool_call_id = None
self._open_tool_use_id = None
@ -344,12 +349,33 @@ class AnthropicStreamEmitter:
)
return events
def _close_open_think(self) -> list[str]:
"""Emit a ``</think>`` delta when the streamed text left a ``<think>``
open. This emitter diffs cumulative snapshots and drops the generator's
final bare shrink, so a reasoning-only reply would otherwise end on an
unclosed tag. Mirrors the chat route's reasoning extractor, which closes
the block on finish; balances the block before it is closed."""
if not self._text_block_open or self._open_think_tags <= 0:
return []
self._open_think_tags = 0
return [
build_anthropic_sse_event(
"content_block_delta",
{
"type": "content_block_delta",
"index": self.block_index,
"delta": {"type": "text_delta", "text": "</think>"},
},
)
]
def _handle_content(self, event: dict) -> list[str]:
cumulative = event.get("text", "")
new_text = cumulative[len(self._prev_text) :]
self._prev_text = cumulative
if not new_text:
return []
self._open_think_tags += new_text.count("<think>") - new_text.count("</think>")
if not self._text_block_open:
events = self._open_text_block()
else:
@ -374,6 +400,7 @@ class AnthropicStreamEmitter:
events = []
if self._text_block_open:
events.extend(self._close_open_think())
events.append(self._close_block())
# Defensive: close a stale open tool_use block before starting another.
elif self._open_tool_call_id is not None:
@ -452,6 +479,7 @@ class AnthropicStreamEmitter:
events.extend(self._open_text_block())
# Reset text tracking for the next synthesis turn
self._prev_text = ""
self._open_think_tags = 0
return events
def _open_text_block(self) -> list[str]:

View file

@ -0,0 +1,109 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Resolve a chat model's assistant-turn-end stop tokens.
Some checkpoints set eos_token_id to a bare document terminator (Qwen3.5 ships
config eos ``<|endoftext|>`` though chat turns end with ``<|im_end|>``, and its
small chat variants ship no generation_config), so generation runs past the turn
and loops -- re-emitting tool calls or hallucinating ``<|im_start|>`` turns.
Turn-end markers are derived from the tokenizer's ``chat_template`` (the tokens it
actually uses to end a turn), not raw vocab membership: a base/coder model can
carry ChatML control tokens in a shared vocab without using them, and a loader
may have synced ``eos_token`` to the document terminator. Dependency-light (no
torch / unsloth) so it is unit-testable without the full inference stack.
"""
from typing import Optional
# Canonical assistant-turn-end markers per chat family.
_CHAT_TURN_END_TOKENS = (
"<|im_end|>", # ChatML: Qwen, Yi
"<|eot_id|>", # Llama 3.x
"<|eom_id|>", # Llama 3.x tool turns
"<end_of_turn>", # Gemma
"<turn|>", # Gemma-4
"<|end|>", # Phi
"<|end_of_turn|>", # OpenChat / Starling (barred, distinct from Gemma's)
)
# harmony/gpt-oss uses <|end|> as a channel delimiter, not the turn end, and has
# its own streamer, so its eos is left untouched.
_HARMONY_MARKERS = ("<|channel|>", "<|constrain|>")
def _eos_id_set(eos_token_id) -> set:
if isinstance(eos_token_id, (list, tuple)):
return {int(t) for t in eos_token_id if t is not None}
if eos_token_id is not None:
return {int(eos_token_id)}
return set()
def _collect_template_text(chat_template) -> str:
"""Flatten a tokenizer ``chat_template`` into one scannable string.
Usually the template is a single jinja string, but multi-variant models
(e.g. Hermes-3: a ``default`` plus a ``tool_use`` template) expose it as a
``{name: template}`` dict -- or, as stored in tokenizer_config.json, a list
of ``{"name": ..., "template": ...}`` dicts. Scanning only the ``str`` case
would skip turn-end detection for those valid models, so gather every string
leaf (variant names are harmless: they never contain the markers).
"""
if isinstance(chat_template, str):
return chat_template
if isinstance(chat_template, dict):
values = chat_template.values()
elif isinstance(chat_template, (list, tuple)):
values = chat_template
else:
return ""
parts = [_collect_template_text(v) for v in values]
return "\n".join(p for p in parts if p)
def resolve_chat_turn_end_eos_ids_using(template_tokenizer, id_tokenizer) -> list:
"""eos of ``id_tokenizer`` plus any canonical turn-end marker the
``template_tokenizer``'s chat_template uses, resolved to ids on ``id_tokenizer`` --
the tokenizer generation actually uses.
Pass the same tokenizer for both at load time. After a mapped ``get_chat_template``
pass the MAPPED tokenizer as ``template_tokenizer`` (it carries the effective
template) and the ORIGINAL generation tokenizer as ``id_tokenizer``: a mapped
template registered ``map_eos_token=True`` can hand back a tokenizer whose vocab
folds the turn-end token onto the doc-eos id, and generate_stream re-reads the
original tokenizer, so resolving ids on the mapped tokenizer would store the wrong
(doc-eos) id and let generation run past the real turn marker."""
ids = _eos_id_set(getattr(id_tokenizer, "eos_token_id", None))
template = _collect_template_text(getattr(template_tokenizer, "chat_template", None))
if not template or any(h in template for h in _HARMONY_MARKERS):
return sorted(ids)
unk = getattr(id_tokenizer, "unk_token_id", None)
for marker in _CHAT_TURN_END_TOKENS:
if marker in template:
try:
tid = id_tokenizer.convert_tokens_to_ids(marker)
except Exception:
tid = None
if tid is not None and tid != unk and int(tid) >= 0:
ids.add(int(tid))
return sorted(ids)
def resolve_chat_turn_end_eos_ids(tokenizer) -> list:
"""tokenizer.eos plus any canonical turn-end marker the model's chat_template
actually uses. Cheap (convert_tokens_to_ids per marker, no get_vocab); intended
to be resolved once at load. Returns eos unchanged for harmony templates."""
return resolve_chat_turn_end_eos_ids_using(tokenizer, tokenizer)
def chat_eos_repair(current_eos, turn_end_ids) -> Optional[list]:
"""Merged eos_token_id list, or None if ``current_eos`` already covers every
resolved turn-end id. Used to repair a model's generation_config at load so
every ``.generate()`` path (vision, tool loops) stops at the turn boundary."""
if not turn_end_ids:
return None
current_set = _eos_id_set(current_eos)
if set(turn_end_ids) <= current_set:
return None
return sorted(current_set | set(turn_end_ids))

View file

@ -3,11 +3,96 @@
"""
Dependency-light wrapper around tokenizer.apply_chat_template with a kwarg
fallback for templates that reject reasoning/tools args.
fallback for templates that reject reasoning/tools args, plus the shared
native-chat-template fallback used by the transformers and MLX backends.
"""
import copy
import json
import logging
from typing import Optional
_THINK_OPEN = "<think>"
_THINK_CLOSE = "</think>"
def detect_think_prefill(prompt: Optional[str], special_tokens = None) -> str:
"""Return the trailing open ``<think>`` prefill of a rendered prompt.
Reasoning templates (Qwen3.6, DeepSeek-R1-style) end the generation
prompt with ``<think>\\n`` so the model starts reasoning immediately.
Because that opening tag is part of the *prompt*, skip_prompt streaming
never emits it, and the frontend's ``<think>``/``</think>`` parser shows
the reasoning as plain text instead of a thinking block. (The GGUF path
is unaffected: llama-server's reasoning parser returns
``reasoning_content``, which gets re-wrapped in think tags.)
Returns the exact prompt tail to re-emit at the start of the generated
stream (e.g. ``"<think>\\n"``), or ``""`` when the prompt does not end
with an open think block, including the ``enable_thinking=False`` case
where templates prefill an already-closed ``<think>\\n\\n</think>``.
``special_tokens`` is the tokenizer's special-token list. If ``</think>``
is one, the streamer's skip_special_tokens strips the model's closing tag,
so re-emitting the open would leave an unclosed block that swallows the
answer. In that case return ``""`` and fall back to plain text.
"""
if not prompt:
return ""
open_idx = prompt.rfind(_THINK_OPEN)
if open_idx == -1:
return ""
tail = prompt[open_idx:]
if _THINK_CLOSE in tail or tail.strip() != _THINK_OPEN:
return ""
if special_tokens and _THINK_CLOSE in set(special_tokens):
return ""
return tail
logger = logging.getLogger(__name__)
def _normalize_tool_call_arguments(messages: list) -> list:
"""Coerce each assistant ``tool_calls[].function.arguments`` from a JSON
string to a dict.
The OpenAI wire format carries ``arguments`` as a JSON string, but some chat
templates (e.g. the stricter Qwen tool templates shipped with mlx-community
checkpoints) iterate ``arguments.items()`` and raise
``TypeError: Can only get item pairs from a mapping.`` on the string form
when a prior tool call is re-rendered on the next turn. A dict works on both
strict and lenient templates, so parse the string; leave non-JSON or non-dict
values untouched. Returns the original list unchanged when nothing needed
coercing (no copy)."""
mutated = False
out: list = []
for msg in messages:
tool_calls = msg.get("tool_calls") if isinstance(msg, dict) else None
if not tool_calls:
out.append(msg)
continue
new_calls = []
msg_changed = False
for call in tool_calls:
fn = call.get("function") if isinstance(call, dict) else None
args = fn.get("arguments") if isinstance(fn, dict) else None
if isinstance(args, str):
try:
parsed = json.loads(args)
except (ValueError, TypeError):
parsed = None
if isinstance(parsed, dict):
call = {**call, "function": {**fn, "arguments": parsed}}
msg_changed = True
new_calls.append(call)
if msg_changed:
out.append({**msg, "tool_calls": new_calls})
mutated = True
else:
out.append(msg)
return out if mutated else messages
def apply_chat_template_for_generation(
tokenizer,
@ -38,21 +123,209 @@ def apply_chat_template_for_generation(
attempts.append(dict(reasoning_kwargs))
attempts.append({})
last_exc: Optional[Exception] = None
for kwargs in attempts:
def _render(msgs: list) -> str:
last_exc: Optional[Exception] = None
for kwargs in attempts:
try:
return tokenizer.apply_chat_template(
msgs,
tokenize = False,
add_generation_prompt = True,
**kwargs,
)
except TypeError as e:
last_exc = e
continue
except Exception as e:
last_exc = e
break
if last_exc is not None:
raise last_exc
raise RuntimeError("apply_chat_template_for_generation: no attempt produced a result")
try:
return _render(messages)
except Exception:
# Strict tool templates reject the JSON-string ``arguments`` form via
# TypeError or a broad Jinja raise_exception, so retry with dicts coerced.
# Original messages render first, so working templates stay byte-identical.
normalized = _normalize_tool_call_arguments(messages)
if normalized is messages:
raise
return _render(normalized)
def render_native_template(
*,
model_info: dict,
active_model_name: Optional[str],
messages: list,
tools: list,
enable_thinking: Optional[bool] = None,
reasoning_effort: Optional[str] = None,
preserve_thinking: Optional[bool] = None,
apply_fn = None,
hf_token: Optional[str] = None,
) -> Optional[str]:
"""Render ``messages`` + ``tools`` with the model's NATIVE chat template.
Some Unsloth override templates (e.g. ``mistral``, ``gemma-4``) do not emit
the ``tools`` schema, so a tool-calling turn silently stops advertising tools.
The native template ships in the model repo and carries the family's
tool-calling syntax. It is loaded straight from the repo (bypassing any
override on the live tokenizer) and cached on ``model_info``. Returns the
rendered prompt only if the native template actually emits the tools (render
differs with vs without tools); otherwise ``None``.
``hf_token`` is the token the model was loaded with -- passed to the repo load
so a gated/private model's native template can still be fetched (otherwise the
fallback fails silently and keeps the override prompt that dropped tools).
``trust_remote_code`` is sourced from ``model_info`` (the value the model was
actually loaded with) rather than a call-site argument, so the native-template
reload uses exactly the consent already granted at load. A custom-code tokenizer
repo raises in ``AutoTokenizer.from_pretrained`` unless ``trust_remote_code`` is
passed, so without this the fallback fails silently and keeps the tool-dropping
prompt for a model the user already consented to run remote code for. For a LoRA
adapter the reload targets the base model, whose remote code was gated and loaded
under the same stored flag, so re-passing it executes no unconsented code.
"""
# ``apply_fn`` lets a backend inject its own render; defaults to the module helper.
if apply_fn is None:
apply_fn = apply_chat_template_for_generation
native_tpl = model_info.get("native_chat_template")
if native_tpl is None:
# A LoRA adapter's native template lives on the base model, not the adapter id.
template_source = model_info.get("base_model") or active_model_name
# Re-use the load-time trust_remote_code so a custom-code tokenizer repo can
# instantiate its class (the stored flag already covers template_source).
trust_remote_code = bool(model_info.get("trust_remote_code", False))
try:
return tokenizer.apply_chat_template(
messages,
tokenize = False,
add_generation_prompt = True,
**kwargs,
from transformers import AutoTokenizer
nt = AutoTokenizer.from_pretrained(
template_source,
token = hf_token if hf_token and hf_token.strip() else None,
trust_remote_code = trust_remote_code,
)
except TypeError as e:
last_exc = e
continue
except Exception as e:
last_exc = e
break
if last_exc is not None:
raise last_exc
raise RuntimeError("apply_chat_template_for_generation: no attempt produced a result")
native_tpl = nt.chat_template or False
except Exception as exc:
logger.warning(
"Could not load native chat template for '%s': %s",
template_source,
exc,
)
# A failed fetch is not "no template": leave the sentinel unset so the next
# call retries (caching False would pin the tool-dropping override).
return None
model_info["native_chat_template"] = native_tpl
if not native_tpl:
return None
tokenizer = model_info.get("tokenizer") or model_info.get("processor")
if tokenizer is None:
return None
tokenizer = getattr(tokenizer, "tokenizer", tokenizer)
# Render on a shallow copy: mutating the shared tokenizer.chat_template (outside the
# generation lock) races concurrent requests.
try:
render_tokenizer = copy.copy(tokenizer)
render_tokenizer.chat_template = native_tpl
except Exception as exc:
logger.warning(
"Could not clone tokenizer for native-template render of '%s': %s",
active_model_name,
exc,
)
return None
try:
with_tools = apply_fn(
render_tokenizer,
messages,
tools = tools,
enable_thinking = enable_thinking,
reasoning_effort = reasoning_effort,
preserve_thinking = preserve_thinking,
)
no_tools = apply_fn(
render_tokenizer,
messages,
tools = None,
enable_thinking = enable_thinking,
reasoning_effort = reasoning_effort,
preserve_thinking = preserve_thinking,
)
except Exception as exc:
logger.warning(
"Native-template tool render failed for '%s': %s",
active_model_name,
exc,
)
return None
return with_tools if with_tools != no_tools else None
def render_with_native_template_fallback(
*,
formatted_prompt: str,
tokenizer,
model_info: dict,
active_model_name: Optional[str],
messages: list,
tools: Optional[list],
enable_thinking: Optional[bool] = None,
reasoning_effort: Optional[str] = None,
preserve_thinking: Optional[bool] = None,
apply_fn = None,
hf_token: Optional[str] = None,
) -> str:
"""Return ``formatted_prompt``, swapping in a native-template render when an
override template dropped the ``tools`` schema.
If ``tools`` were requested but the live render is identical with and without
them (detected by comparison, robust against tool names in the system prompt),
re-render with the model's native template. Shared by the transformers and MLX
backends so both advertise tools consistently. ``hf_token`` is forwarded so a
gated/private model's native template can still be fetched."""
if not tools:
return formatted_prompt
if apply_fn is None:
apply_fn = apply_chat_template_for_generation
# Probe whether the live template dropped the schema. A tools-requiring template
# can raise here; on any error keep the valid tools prompt rather than lose it.
try:
probe_no_tools = apply_fn(
tokenizer,
messages,
tools = None,
enable_thinking = enable_thinking,
reasoning_effort = reasoning_effort,
preserve_thinking = preserve_thinking,
)
except Exception as exc:
logger.warning(
"No-tools probe failed for '%s'; keeping the existing tools prompt: %s",
active_model_name,
exc,
)
return formatted_prompt
if formatted_prompt != probe_no_tools:
return formatted_prompt # template already emits the tools schema
native_prompt = render_native_template(
model_info = model_info,
active_model_name = active_model_name,
messages = messages,
tools = tools,
enable_thinking = enable_thinking,
reasoning_effort = reasoning_effort,
preserve_thinking = preserve_thinking,
apply_fn = apply_fn,
hf_token = hf_token,
)
if native_prompt:
logger.info(
"Override template for '%s' dropped tool schemas; using the model's "
"native template for this tool-calling turn.",
active_model_name,
)
return native_prompt
return formatted_prompt

View file

@ -8,6 +8,7 @@ import utils.hardware.hardware as hw
DEFAULT_MODELS_GGUF = [
"unsloth/Qwen3.6-27B-MTP-GGUF",
"unsloth/Qwen3.6-35B-A3B-MTP-GGUF",
"unsloth/DeepSeek-V4-Flash-GGUF",
"unsloth/gemma-4-E2B-it-GGUF",
"unsloth/gemma-4-E4B-it-GGUF",
"unsloth/gemma-4-31B-it-GGUF",
@ -27,6 +28,7 @@ DEFAULT_MODELS_GGUF = [
DEFAULT_MODELS_STANDARD = [
"unsloth/Qwen3.6-27B-MTP-GGUF",
"unsloth/Qwen3.6-35B-A3B-MTP-GGUF",
"unsloth/DeepSeek-V4-Flash-GGUF",
"unsloth/gemma-4-E2B-it-GGUF",
"unsloth/gemma-4-E4B-it-GGUF",
"unsloth/gemma-4-31B-it-GGUF",

View file

@ -27,6 +27,11 @@ 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 core.inference.chat_eos import (
chat_eos_repair,
resolve_chat_turn_end_eos_ids_using,
)
from core.inference.presence_penalty import _make_presence_penalty_processor
from io import StringIO
import structlog
from loggers import get_logger
@ -210,6 +215,50 @@ class InferenceBackend:
# API uses -1 to disable top-k; transformers uses 0.
return 0 if top_k < 0 else top_k
def _resolve_chat_eos(self, model_name: str) -> None:
"""Resolve this chat model's assistant-turn-end stop tokens once at load,
cache them in model_info, and repair generation_config so every
``.generate()`` path stops at the turn boundary.
Some checkpoints (e.g. Qwen3.5 / Qwen3.6 small chat models) end turns with
``<|im_end|>`` but ship ``config.eos_token_id = <|endoftext|>`` and no
``generation_config.json``, so paths that read ``generation_config`` (the
vision path, tool loops) run past the turn and loop. Turn-end markers are
derived from the chat_template (see chat_eos.resolve_chat_turn_end_eos_ids),
so base/coder models and harmony templates are left untouched.
"""
info = self.models.get(model_name) or {}
model = info.get("model")
container = info.get("tokenizer")
tokenizer = getattr(container, "tokenizer", container) # unwrap processors
if model is None or tokenizer is None:
return
# Vision models carry the chat_template on the processor, not the inner
# tokenizer. Read markers from whichever has one, but resolve ids on the
# generation tokenizer, else the vision path misses the turn-end token.
template_source = container if getattr(container, "chat_template", None) else tokenizer
try:
turn_end_ids = resolve_chat_turn_end_eos_ids_using(template_source, tokenizer)
except Exception as e: # never block a load on eos resolution
logger.warning("Chat turn-end eos resolution failed for %s: %s", model_name, e)
return
info["chat_turn_end_eos_ids"] = turn_end_ids
gen = getattr(model, "generation_config", None)
if gen is None:
return
repaired = chat_eos_repair(gen.eos_token_id, turn_end_ids)
if repaired is None:
return
previous = gen.eos_token_id
gen.eos_token_id = repaired
logger.info(
"Repaired generation_config.eos_token_id for %s: %s -> %s",
model_name,
previous,
repaired,
)
def load_model(
self,
config: ModelConfig,
@ -221,6 +270,9 @@ class InferenceBackend:
gpu_ids: Optional[list[int]] = None,
) -> bool:
"""Load any model: base, LoRA adapter, text, or vision."""
# Keep the token so the native-template fallback can fetch a
# gated model's repo template later during generation.
self._hf_token = hf_token
# GGUF uses max_seq_length=0 as "model default"; Unsloth crashes on it.
if max_seq_length <= 0:
max_seq_length = 2048
@ -231,6 +283,8 @@ class InferenceBackend:
# Already loaded?
if model_name in self.models and self.models[model_name].get("model"):
logger.info(f"Model {model_name} already loaded")
if hf_token:
self.models[model_name]["hf_token"] = hf_token
self.active_model_name = model_name
return True
@ -246,6 +300,14 @@ class InferenceBackend:
)
self.models[model_name] = {
# Per-model token: the native-template fallback must use the
# token this model was loaded with, not whichever loaded last.
"hf_token": hf_token,
# Per-model consent: the native-template reload must re-use the
# exact trust_remote_code this model (and a LoRA's base) was loaded
# with, so a custom-code tokenizer repo can be re-fetched without
# executing any code the user did not already consent to.
"trust_remote_code": trust_remote_code,
"is_vision": config.is_vision,
"is_lora": config.is_lora,
"is_audio": config.is_audio,
@ -496,6 +558,7 @@ class InferenceBackend:
max_seq_length,
)
self._resolve_chat_eos(model_name)
self._load_chat_template_info(model_name)
self.active_model_name = model_name
@ -766,9 +829,11 @@ class InferenceBackend:
preserve_thinking: Optional[bool] = None,
max_tool_iterations: int = 25,
auto_heal_tool_calls: bool = True,
nudge_tool_calls: Optional[bool] = None,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
rag_scope: Optional[dict] = None,
presence_penalty: float = 0.0,
):
"""Run an agentic tool loop on top of ``generate_chat_response``.
@ -802,6 +867,7 @@ class InferenceBackend:
enable_thinking = enable_thinking,
reasoning_effort = reasoning_effort,
preserve_thinking = preserve_thinking,
presence_penalty = presence_penalty,
)
initial = list(messages)
@ -815,6 +881,7 @@ class InferenceBackend:
execute_tool = execute_tool,
cancel_event = cancel_event,
auto_heal_tool_calls = auto_heal_tool_calls,
nudge_tool_calls = nudge_tool_calls,
max_tool_iterations = max_tool_iterations,
tool_call_timeout = tool_call_timeout,
session_id = session_id,
@ -837,12 +904,14 @@ class InferenceBackend:
enable_thinking: Optional[bool] = None,
reasoning_effort: Optional[str] = None,
preserve_thinking: Optional[bool] = None,
presence_penalty: float = 0.0,
) -> Generator[str, None, None]:
"""Generate response for text or vision models (lock held by background thread).
``tools`` / ``enable_thinking`` / ``reasoning_effort`` / ``preserve_thinking``
are forwarded into ``apply_chat_template`` so templates that understand them
(Qwen3, Llama 3.1+, gpt-oss harmony) advertise tool schemas / reasoning controls.
``presence_penalty`` matches the GGUF sampling path (0 disables it).
"""
yield from self._generate_chat_response_inner(
messages = messages,
@ -859,6 +928,7 @@ class InferenceBackend:
enable_thinking = enable_thinking,
reasoning_effort = reasoning_effort,
preserve_thinking = preserve_thinking,
presence_penalty = presence_penalty,
)
def _generate_chat_response_inner(
@ -878,6 +948,7 @@ class InferenceBackend:
enable_thinking: Optional[bool] = None,
reasoning_effort: Optional[str] = None,
preserve_thinking: Optional[bool] = None,
presence_penalty: float = 0.0,
) -> Generator[str, None, None]:
"""Inner generation logic, called by generate_chat_response and
generate_with_adapter_control.
@ -917,6 +988,7 @@ class InferenceBackend:
max_new_tokens,
repetition_penalty,
cancel_event = cancel_event,
presence_penalty = presence_penalty,
)
return
else:
@ -946,6 +1018,22 @@ class InferenceBackend:
tokenizer,
chat_template = template_name,
)
# The mapper installs the effective template only now, at generate
# time, so re-resolve and UNION into the load-time cache (never
# overwrite). get_chat_template can return a remapped tokenizer
# (turn-end folded onto doc-eos) while generate_stream reads the
# original, so take marker strings from the mapped template but
# resolve their ids on the original.
try:
_gen_tok = model_info.get("tokenizer") or tokenizer
refreshed = resolve_chat_turn_end_eos_ids_using(
getattr(tokenizer, "tokenizer", tokenizer),
getattr(_gen_tok, "tokenizer", _gen_tok),
)
existing = model_info.get("chat_turn_end_eos_ids") or []
model_info["chat_turn_end_eos_ids"] = sorted(set(existing) | set(refreshed))
except Exception as e:
logger.warning(f"Could not refresh chat turn-end eos after template: {e}")
else:
logger.info(
f"No registered Unsloth template for {self.active_model_name}, using tokenizer default"
@ -975,6 +1063,27 @@ class InferenceBackend:
reasoning_effort = reasoning_effort,
preserve_thinking = preserve_thinking,
)
# If tools were requested but the (possibly overridden) template ignored
# them, fall back to the model's native template (shared with MLX).
from core.inference.chat_template_helpers import (
render_with_native_template_fallback,
)
formatted_prompt = render_with_native_template_fallback(
formatted_prompt = formatted_prompt,
tokenizer = tokenizer,
model_info = model_info,
active_model_name = self.active_model_name,
messages = template_messages,
tools = tools,
enable_thinking = enable_thinking,
reasoning_effort = reasoning_effort,
preserve_thinking = preserve_thinking,
apply_fn = self._apply_chat_template_for_generation,
hf_token = model_info.get("hf_token"),
)
logger.debug(f"Formatted prompt: {formatted_prompt[:200]}...")
except Exception as e:
logger.error(f"Error applying chat template: {e}")
@ -992,6 +1101,7 @@ class InferenceBackend:
repetition_penalty,
cancel_event = cancel_event,
_adapter_state = _adapter_state,
presence_penalty = presence_penalty,
)
def _generate_vision_response(
@ -1006,6 +1116,7 @@ class InferenceBackend:
max_new_tokens,
repetition_penalty,
cancel_event = None,
presence_penalty: float = 0.0,
) -> Generator[str, None, None]:
"""Handle vision model generation with true token-by-token streaming."""
model_info = self.models[self.active_model_name]
@ -1067,13 +1178,22 @@ class InferenceBackend:
add_special_tokens = False,
return_tensors = "pt",
).to(model.device)
prompt_text = input_text
else:
# Text-only path for a vision model
formatted_prompt = self.format_chat_prompt(messages, system_prompt)
inputs = raw_tokenizer(formatted_prompt, return_tensors = "pt").to(model.device)
prompt_text = formatted_prompt
# Stream with TextIteratorStreamer + background thread
try:
from core.inference.chat_template_helpers import detect_think_prefill
# Re-emit an open <think> prefill swallowed by skip_prompt (see
# generate_stream).
think_prefix = detect_think_prefill(
prompt_text, getattr(raw_tokenizer, "all_special_tokens", None)
)
from transformers import TextIteratorStreamer
import threading
@ -1095,6 +1215,14 @@ class InferenceBackend:
top_k = top_k,
min_p = min_p,
)
# Presence penalty (GGUF parity) for VLM chat.
_vision_input_ids = inputs.get("input_ids") if hasattr(inputs, "get") else None
if _vision_input_ids is not None:
_pp = _make_presence_penalty_processor(
presence_penalty, int(_vision_input_ids.shape[1])
)
if _pp is not None:
generation_kwargs["logits_processor"] = _pp
err: dict[str, str] = {}
@ -1114,7 +1242,11 @@ class InferenceBackend:
thread = threading.Thread(target = generate_fn)
thread.start()
output = ""
output = think_prefix
# Emit the prefilled <think> before the first token so the block
# renders during prompt prefill (which can take seconds).
if think_prefix:
yield think_prefix
from queue import Empty
generation_complete = False
@ -1323,11 +1455,13 @@ class InferenceBackend:
repetition_penalty: float = 1.0,
cancel_event = None,
_adapter_state = None,
presence_penalty: float = 0.0,
) -> Generator[str, None, None]:
"""Generate a streaming text response (text models only).
_adapter_state: if not None, the background thread toggles adapters
before model.generate(), under _generation_lock.
``presence_penalty`` matches the GGUF sampling path via a logits processor (0 disables it).
"""
if not self.active_model_name:
yield "Error: No active model"
@ -1346,6 +1480,16 @@ class InferenceBackend:
from transformers import TextIteratorStreamer
import threading
from core.inference.chat_template_helpers import detect_think_prefill
# skip_prompt swallows an open <think> prefilled by the template;
# re-emit it so the frontend can render the thinking block.
# gpt-oss emits its own tags via HarmonyTextStreamer.
think_prefix = (
""
if self._is_gpt_oss_model()
else detect_think_prefill(prompt, getattr(tokenizer, "all_special_tokens", None))
)
# gpt-oss models: HarmonyTextStreamer parses the multi-channel
# harmony protocol into <think> tags
@ -1382,11 +1526,18 @@ class InferenceBackend:
min_p = min_p,
repetition_penalty = repetition_penalty,
do_sample = temperature > 0,
eos_token_id = tokenizer.eos_token_id,
# Resolved once at load (chat_template-derived turn-end tokens).
eos_token_id = model_info.get("chat_turn_end_eos_ids") or tokenizer.eos_token_id,
pad_token_id = tokenizer.eos_token_id
if tokenizer.pad_token_id is None
else tokenizer.pad_token_id,
)
# Presence penalty (GGUF parity); prompt_len excludes prompt tokens.
_pp = _make_presence_penalty_processor(
presence_penalty, int(inputs["input_ids"].shape[1])
)
if _pp is not None:
generation_kwargs["logits_processor"] = _pp
if cancel_event is not None:
from transformers.generation.stopping_criteria import (
StoppingCriteria,
@ -1422,7 +1573,11 @@ class InferenceBackend:
thread = threading.Thread(target = generate_fn)
thread.start()
output = ""
output = think_prefix
# Emit the prefilled <think> before the first token so the block
# renders during prompt prefill (which can take seconds).
if think_prefix:
yield think_prefix
from queue import Empty
generation_complete = False

View file

@ -0,0 +1,368 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Admission control for local llama-server generation requests.
The helpers in this module deliberately know nothing about FastAPI, SSE, or the
OpenAI-compatible route shape. They only coordinate how many upstream generation
requests may be active for one llama-server backend and provide a cancellable
FIFO queue for excess requests.
"""
from __future__ import annotations
import asyncio
import os
import threading
from collections import deque
from dataclasses import dataclass
from typing import Deque, Optional
ADMISSION_CONTROL_ENV = "UNSLOTH_OPENAI_COMPAT_ADMISSION_CONTROL"
ADMISSION_QUEUE_TIMEOUT_ENV = "UNSLOTH_OPENAI_COMPAT_ADMISSION_QUEUE_TIMEOUT"
ADMISSION_KEEPALIVE_INTERVAL_ENV = "UNSLOTH_OPENAI_COMPAT_ADMISSION_KEEPALIVE_INTERVAL"
ADMISSION_MAX_QUEUE_ENV = "UNSLOTH_OPENAI_COMPAT_ADMISSION_MAX_QUEUE"
DEFAULT_ADMISSION_ENABLED = True
DEFAULT_ADMISSION_QUEUE_TIMEOUT_S = None
DEFAULT_ADMISSION_KEEPALIVE_INTERVAL_S = 5.0
DEFAULT_ADMISSION_MAX_QUEUE = 64
@dataclass(frozen = True)
class LlamaAdmissionConfig:
enabled: bool = DEFAULT_ADMISSION_ENABLED
queue_timeout_s: Optional[float] = DEFAULT_ADMISSION_QUEUE_TIMEOUT_S
keepalive_interval_s: float = DEFAULT_ADMISSION_KEEPALIVE_INTERVAL_S
max_queue: Optional[int] = DEFAULT_ADMISSION_MAX_QUEUE
@dataclass(frozen = True)
class LlamaAdmissionSnapshot:
key: str
capacity: int
active: int
queued: int
class LlamaAdmissionError(Exception):
def __init__(
self,
message: str,
*,
snapshot: Optional[LlamaAdmissionSnapshot] = None,
):
super().__init__(message)
self.snapshot = snapshot
class LlamaAdmissionQueueFull(LlamaAdmissionError):
pass
class LlamaAdmissionTimeout(LlamaAdmissionError):
pass
class LlamaAdmissionCancelled(LlamaAdmissionError):
pass
def _bool_env(name: str, default: bool) -> bool:
value = os.environ.get(name)
if value is None or not value.strip():
return default
value = value.strip().lower()
if value in {"1", "true", "yes", "on"}:
return True
if value in {"0", "false", "no", "off"}:
return False
return default
def _optional_positive_float_env(name: str, default: Optional[float]) -> Optional[float]:
value = os.environ.get(name)
if value is None or not value.strip():
return default
try:
parsed = float(value.strip())
except ValueError:
return default
return parsed if parsed > 0 else None
def _positive_float_env(name: str, default: float) -> float:
value = os.environ.get(name)
if value is None or not value.strip():
return default
try:
parsed = float(value.strip())
except ValueError:
return default
return parsed if parsed > 0 else default
def _optional_positive_int_env(name: str, default: Optional[int]) -> Optional[int]:
value = os.environ.get(name)
if value is None or not value.strip():
return default
try:
parsed = int(value.strip())
except ValueError:
return default
return parsed if parsed > 0 else None
def llama_admission_config_from_env() -> LlamaAdmissionConfig:
return LlamaAdmissionConfig(
enabled = _bool_env(ADMISSION_CONTROL_ENV, DEFAULT_ADMISSION_ENABLED),
queue_timeout_s = _optional_positive_float_env(
ADMISSION_QUEUE_TIMEOUT_ENV,
DEFAULT_ADMISSION_QUEUE_TIMEOUT_S,
),
keepalive_interval_s = _positive_float_env(
ADMISSION_KEEPALIVE_INTERVAL_ENV,
DEFAULT_ADMISSION_KEEPALIVE_INTERVAL_S,
),
max_queue = _optional_positive_int_env(
ADMISSION_MAX_QUEUE_ENV,
DEFAULT_ADMISSION_MAX_QUEUE,
),
)
@dataclass
class _Waiter:
loop: asyncio.AbstractEventLoop
future: asyncio.Future
cancelled: bool = False
granted_lease: Optional["LlamaAdmissionLease"] = None
class LlamaAdmissionLease:
def __init__(self, queue: Optional["LlamaAdmissionQueue"]):
self._queue = queue
self._released = False
self._release_lock = threading.Lock()
def release(self) -> None:
queue = None
with self._release_lock:
if self._released:
return
self._released = True
queue = self._queue
if queue is not None:
queue.release()
async def __aenter__(self) -> "LlamaAdmissionLease":
return self
async def __aexit__(self, *_args) -> None:
self.release()
class LlamaAdmissionReservation:
def __init__(
self,
*,
queue: Optional["LlamaAdmissionQueue"],
lease: Optional[LlamaAdmissionLease] = None,
waiter: Optional[_Waiter] = None,
snapshot: Optional[LlamaAdmissionSnapshot] = None,
):
self._queue = queue
self._lease = lease
self._waiter = waiter
self.snapshot = snapshot
@property
def is_cancelled(self) -> bool:
return self._lease is None and self._waiter is None
def lease_nowait(self) -> Optional[LlamaAdmissionLease]:
if self._lease is not None:
return self._lease
if self._waiter is None or not self._waiter.future.done():
return None
if self._waiter.future.cancelled():
self._waiter.cancelled = True
self._waiter = None
return None
self._lease = self._waiter.future.result()
self._waiter = None
return self._lease
async def wait(self, timeout_s: float) -> Optional[LlamaAdmissionLease]:
lease = self.lease_nowait()
if lease is not None:
return lease
if self._waiter is None:
return None
waiter = self._waiter
try:
await asyncio.wait_for(asyncio.shield(waiter.future), timeout = timeout_s)
except asyncio.CancelledError:
if waiter.future.cancelled():
waiter.cancelled = True
if self._waiter is waiter:
self._waiter = None
return None
raise
return self.lease_nowait()
def cancel(self) -> None:
lease = self.lease_nowait()
if lease is not None:
lease.release()
self._lease = None
return
if self._queue is not None and self._waiter is not None:
self._queue.cancel(self._waiter)
self._waiter = None
def snapshot_now(self) -> Optional[LlamaAdmissionSnapshot]:
if self._queue is None:
return self.snapshot
return self._queue.snapshot()
class LlamaAdmissionQueue:
def __init__(self, key: str):
self.key = key
self._lock = threading.Lock()
self._active = 0
self._capacity = 1
self._waiters: Deque[_Waiter] = deque()
def reserve(self, *, capacity: int, config: LlamaAdmissionConfig) -> LlamaAdmissionReservation:
capacity = max(1, int(capacity or 1))
if not config.enabled:
return LlamaAdmissionReservation(
queue = None,
lease = LlamaAdmissionLease(None),
snapshot = LlamaAdmissionSnapshot(self.key, capacity, 0, 0),
)
loop = asyncio.get_running_loop()
with self._lock:
self._capacity = capacity
self._prune_waiters_locked()
self._grant_waiters_locked()
if self._active < self._capacity and not self._waiters:
self._active += 1
return LlamaAdmissionReservation(
queue = self,
lease = LlamaAdmissionLease(self),
snapshot = self._snapshot_locked(),
)
if config.max_queue is not None and len(self._waiters) >= config.max_queue:
raise LlamaAdmissionQueueFull(
"llama-server generation queue is full",
snapshot = self._snapshot_locked(),
)
waiter = _Waiter(
loop = loop,
future = loop.create_future(),
)
self._waiters.append(waiter)
return LlamaAdmissionReservation(
queue = self,
waiter = waiter,
snapshot = self._snapshot_locked(),
)
def release(self) -> None:
with self._lock:
if self._active > 0:
self._active -= 1
self._grant_waiters_locked()
def cancel(self, waiter: _Waiter) -> None:
lease_to_release = None
with self._lock:
waiter.cancelled = True
try:
self._waiters.remove(waiter)
except ValueError:
pass
if waiter.granted_lease is not None:
lease_to_release = waiter.granted_lease
waiter.granted_lease = None
if not waiter.future.done():
waiter.loop.call_soon_threadsafe(waiter.future.cancel)
if lease_to_release is not None:
lease_to_release.release()
def snapshot(self) -> LlamaAdmissionSnapshot:
with self._lock:
self._prune_waiters_locked()
return self._snapshot_locked()
def is_idle(self) -> bool:
with self._lock:
self._prune_waiters_locked()
return self._active == 0 and not self._waiters
def _grant_waiters_locked(self) -> None:
self._prune_waiters_locked()
while self._waiters and self._active < self._capacity:
waiter = self._waiters.popleft()
if waiter.cancelled or waiter.future.done():
continue
self._active += 1
lease = LlamaAdmissionLease(self)
waiter.granted_lease = lease
waiter.loop.call_soon_threadsafe(self._deliver_lease, waiter, lease)
def _deliver_lease(self, waiter: _Waiter, lease: LlamaAdmissionLease) -> None:
if waiter.cancelled or waiter.future.done():
waiter.granted_lease = None
if not waiter.future.done():
waiter.future.cancel()
lease.release()
return
try:
waiter.future.set_result(lease)
waiter.granted_lease = None
except asyncio.InvalidStateError:
waiter.granted_lease = None
lease.release()
def _prune_waiters_locked(self) -> None:
self._waiters = deque(
waiter for waiter in self._waiters if not waiter.cancelled and not waiter.future.done()
)
def _snapshot_locked(self) -> LlamaAdmissionSnapshot:
return LlamaAdmissionSnapshot(
key = self.key,
capacity = self._capacity,
active = self._active,
queued = len(self._waiters),
)
_QUEUES_LOCK = threading.Lock()
_QUEUES: dict[str, LlamaAdmissionQueue] = {}
def get_llama_admission_queue(key: str) -> LlamaAdmissionQueue:
with _QUEUES_LOCK:
queue = _QUEUES.get(key)
if queue is None:
queue = LlamaAdmissionQueue(key)
_QUEUES[key] = queue
# base_url carries a fresh ephemeral port on every model load, so
# each load registers a new key. Drop the now-idle queues from prior
# loads so the registry can't grow without bound on a long-running
# server. Queues with in-flight requests are kept until they drain.
for stale_key in [k for k in _QUEUES if k != key and _QUEUES[k].is_idle()]:
del _QUEUES[stale_key]
return queue
def reset_llama_admission_queues() -> None:
with _QUEUES_LOCK:
_QUEUES.clear()

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@ Drop-in replacement for InferenceBackend — same interface, uses mlx-lm/mlx-vlm
instead of torch/transformers for model loading and generation.
"""
import os
import threading
from typing import Optional, Generator
from core.inference.runtime_context import runtime_context_length
@ -41,6 +42,87 @@ def _build_generation_stats(prompt_n, prompt_tps, gen_n, gen_tps):
}
def _mlx_distributed_rank_size(group = None):
"""Return ``(rank, world_size)`` for an optional MLX distributed group."""
if group is None:
return 0, 1
rank = int(group.rank())
world_size = int(group.size())
if world_size < 1:
raise ValueError(f"Invalid MLX distributed world_size={world_size}.")
if rank < 0 or rank >= world_size:
raise ValueError(f"Invalid MLX distributed rank={rank} for world_size={world_size}.")
return rank, world_size
def _mlx_distributed_backend_from_env():
if os.environ.get("MLX_JACCL_COORDINATOR") and os.environ.get("MLX_IBV_DEVICES"):
return "jaccl"
return None
def _init_mlx_distributed():
"""Initialize MLX distributed state, falling back to singleton metadata."""
import mlx.core as mx
group = None
rank = 0
world_size = 1
distributed = getattr(mx, "distributed", None)
init = getattr(distributed, "init", None) if distributed is not None else None
if callable(init):
backend = _mlx_distributed_backend_from_env()
if backend is None:
group = init()
else:
try:
group = init(backend = backend)
except TypeError:
group = init()
if group is not None:
rank, world_size = _mlx_distributed_rank_size(group)
return group, rank, world_size
def _make_mlx_presence_penalty_processor(penalty: float):
"""Presence penalty as an mlx_lm/mlx_vlm logits processor, matching the safetensors path.
generate_step calls processors as ``fn(tokens, logits)`` with ``tokens`` the
full running sequence; the first call is prompt-only, so latch that length
and penalize only after it.
"""
state = {"prompt_len": None}
def _processor(tokens, logits):
if state["prompt_len"] is None:
# First call is prompt-only; latch its length.
state["prompt_len"] = int(tokens.shape[0])
return logits
generated = tokens[state["prompt_len"] :]
if generated.size == 0:
return logits
import mlx.core as mx
vocab = logits.shape[-1]
# Bound ids to [0, vocab) before indexing logits: MLX does no bounds
# checking and out-of-bounds indexing is undefined behavior (crash /
# corruption), unlike torch's harmless negative wrap. MLX also lacks
# boolean-mask filtering, so out-of-range/negative ids route to a
# scratch slot at index vocab (dropped before the subtract) that never
# collides with a real token: real ids (including 0) are penalized
# once, strays ignored.
valid = (generated >= 0) & (generated < vocab)
safe = mx.where(valid, generated, vocab).astype(mx.int32)
# Scatter penalty into a (vocab + 1)-wide mask: duplicate ids are
# idempotent (presence applies once per token); scratch column dropped.
mask = mx.zeros((vocab + 1,), dtype = logits.dtype)
mask[safe] = penalty
logits = logits - mask[:vocab]
return logits
return _processor
class MLXInferenceBackend:
def __init__(self):
self.models = {}
@ -49,7 +131,7 @@ class MLXInferenceBackend:
self.loaded_local_models = []
self.device = "mlx"
self._generation_lock = threading.Lock()
# usage/timings of the latest generation; shipped on gen_done.
# usage/timings of the latest generation, shipped on gen_done.
self.last_generation_stats = None
self._model = None
@ -57,6 +139,9 @@ class MLXInferenceBackend:
self._processor = None
self._is_vlm = False
self._config = {}
self._distributed_group = None
self._distributed_rank = 0
self._distributed_world_size = 1
# Recorded for unload to release pinned memory back to the OS.
self._memory_limits_applied = {}
@ -101,16 +186,26 @@ class MLXInferenceBackend:
trust_remote_code = False,
gpu_ids = None,
dtype = None,
parallel_mode = None,
distributed_group = None,
) -> bool:
import mlx.core as mx
# Keep the token so the native-template fallback can fetch a gated
# model's repo template during generation.
self._hf_token = hf_token
model_name = config.identifier if hasattr(config, "identifier") else str(config)
is_vision = getattr(config, "is_vision", False)
distributed_rank, distributed_size = _mlx_distributed_rank_size(distributed_group)
is_distributed = distributed_group is not None and distributed_size > 1
self._distributed_group = distributed_group
self._distributed_rank = distributed_rank
self._distributed_world_size = distributed_size
# GGUF guard. GGUF models are served by llama-server in the parent
# process, not mlx-lm here. Reaching this with is_gguf=True means the
# route's first detection flaked (transient HF Hub) but the subprocess
# re-detected GGUF; raise loudly instead of a cryptic mlx_lm error.
# GGUF guard: GGUF is served by llama-server in the parent process,
# not mlx-lm. Reaching here with is_gguf=True means the route's
# detection flaked but the subprocess re-detected GGUF; raise loudly
# instead of a cryptic mlx_lm error.
if getattr(config, "is_gguf", False):
raise RuntimeError(
f"MLXInferenceBackend cannot load GGUF model '{model_name}': "
@ -129,11 +224,26 @@ class MLXInferenceBackend:
is_lora = getattr(config, "is_lora", False)
logger.info(
"Loading %s via %s (is_lora=%s)",
"Loading %s via %s (is_lora=%s, distributed=%s, rank=%s/%s, mode=%s)",
model_name,
"mlx-vlm" if is_vision else "mlx-lm",
is_lora,
is_distributed,
distributed_rank,
distributed_size,
parallel_mode,
)
if is_distributed and parallel_mode not in ("pipeline", "tensor"):
raise ValueError(
"Unsloth: distributed MLX inference requires parallel_mode='pipeline' "
"or parallel_mode='tensor'."
)
if is_distributed and is_lora:
raise ValueError(
"Unsloth: distributed MLX inference for LoRA adapter repos "
"is not supported yet. Merge/export the adapter into an MLX model "
"before distributed inference."
)
try:
from unsloth_zoo.mlx.loader import FastMLXModel
@ -143,14 +253,23 @@ class MLXInferenceBackend:
"(unsloth_zoo.mlx.loader). Reinstall via install.sh on Apple Silicon."
) from e
load_kwargs = {
"max_seq_length": max_seq_length,
"dtype": dtype,
"load_in_4bit": load_in_4bit,
"token": hf_token,
"trust_remote_code": trust_remote_code,
"text_only": False if is_vision else True,
}
if is_distributed:
if parallel_mode == "pipeline":
load_kwargs["pipeline_group"] = distributed_group
else:
load_kwargs["tensor_group"] = distributed_group
model, tokenizer_or_processor = FastMLXModel.from_pretrained(
model_name,
max_seq_length = max_seq_length,
dtype = dtype,
load_in_4bit = load_in_4bit,
token = hf_token,
trust_remote_code = trust_remote_code,
text_only = False if is_vision else True,
**load_kwargs,
)
if is_vision:
@ -168,18 +287,25 @@ class MLXInferenceBackend:
self.active_model_name = model_name
self.models[model_name] = {
# Per-model token for the native-template fallback (matches transformers).
"hf_token": hf_token,
# Per-model trust_remote_code reused by the native-template reload (matches transformers).
"trust_remote_code": trust_remote_code,
"model": self._model,
"tokenizer": self._tokenizer,
"processor": self._processor,
"is_vision": is_vision,
"is_lora": getattr(config, "is_lora", False),
# For a LoRA adapter the native chat template lives on the base model.
"base_model": getattr(config, "base_model", None)
if getattr(config, "is_lora", False)
else None,
"is_audio": False,
"audio_type": None,
"has_audio_input": False,
"context_length": runtime_context_length(self._model, max_seq_length),
}
# Capture chat_template_info so the worker IPC reply ships it back and
# the route layer classifies capabilities like the other paths.
# Capture chat_template_info for the worker IPC reply and route capability classification.
self._populate_chat_template_info(model_name)
logger.info("Model %s loaded successfully", model_name)
@ -237,6 +363,9 @@ class MLXInferenceBackend:
self._model = None
self._tokenizer = None
self._processor = None
self._distributed_group = None
self._distributed_rank = 0
self._distributed_world_size = 1
if self.active_model_name == model_name:
self.active_model_name = None
gc.collect()
@ -264,12 +393,12 @@ class MLXInferenceBackend:
max_new_tokens = 256,
repetition_penalty = 1.0,
cancel_event = None,
# Reasoning / tool kwargs forwarded by the route + worker; rendered via
# apply_chat_template_for_generation like the transformers path.
# Reasoning / tool kwargs, rendered via apply_chat_template_for_generation (transformers parity).
tools = None,
enable_thinking = None,
reasoning_effort = None,
preserve_thinking = None,
presence_penalty = 0.0,
) -> Generator[str, None, None]:
if self._model is None:
raise RuntimeError("No model loaded")
@ -277,7 +406,6 @@ class MLXInferenceBackend:
# Reset so a failed run cannot surface stale stats.
self.last_generation_stats = None
# Build messages with system prompt
full_messages = []
if system_prompt:
full_messages.append({"role": "system", "content": system_prompt})
@ -294,7 +422,6 @@ class MLXInferenceBackend:
{"type": "text", "text": content},
]
elif isinstance(content, list):
# Prepend image if not already present
has_image = any(
p.get("type") == "image" for p in content if isinstance(p, dict)
)
@ -317,6 +444,7 @@ class MLXInferenceBackend:
enable_thinking = enable_thinking,
reasoning_effort = reasoning_effort,
preserve_thinking = preserve_thinking,
presence_penalty = presence_penalty,
)
else:
yield from self._generate_text(
@ -332,6 +460,7 @@ class MLXInferenceBackend:
enable_thinking = enable_thinking,
reasoning_effort = reasoning_effort,
preserve_thinking = preserve_thinking,
presence_penalty = presence_penalty,
)
def _generate_text(
@ -349,12 +478,15 @@ class MLXInferenceBackend:
enable_thinking = None,
reasoning_effort = None,
preserve_thinking = None,
presence_penalty = 0.0,
):
from mlx_lm import stream_generate
from mlx_lm.sample_utils import make_sampler, make_logits_processors
from core.inference.chat_template_helpers import (
apply_chat_template_for_generation,
detect_think_prefill,
render_with_native_template_fallback,
)
prompt = apply_chat_template_for_generation(
@ -368,6 +500,34 @@ class MLXInferenceBackend:
if prompt is None:
raise RuntimeError("apply_chat_template returned None — tokenizer may be incompatible")
# Parity with the transformers backend: if the template dropped the
# requested tools, fall back to the native template so MLX text models
# keep advertising them. self._tokenizer is this entry's tokenizer, so
# probe and native render share a renderer. (VLM renders via the
# processor for image tokens and is not wired here.)
model_info = self.models.get(self.active_model_name, {})
prompt = render_with_native_template_fallback(
formatted_prompt = prompt,
tokenizer = self._tokenizer,
model_info = model_info,
active_model_name = self.active_model_name,
messages = messages,
tools = tools,
enable_thinking = enable_thinking,
reasoning_effort = reasoning_effort,
preserve_thinking = preserve_thinking,
hf_token = model_info.get("hf_token"),
)
# An open <think> prefilled by the template lives in the prompt, not
# the generated tokens; re-emit it so the frontend renders the block.
think_prefix = detect_think_prefill(
prompt, getattr(self._tokenizer, "all_special_tokens", None)
)
# Emit it before the first token so the block renders during prefill.
if think_prefix:
yield think_prefix
sampler = make_sampler(
temp = temperature,
top_p = top_p,
@ -375,15 +535,21 @@ class MLXInferenceBackend:
min_p = float(min_p or 0.0),
min_tokens_to_keep = 1,
)
# Only build a logits processor for a non-trivial repetition penalty.
logits_processors = None
# Repetition and/or presence penalty processors (GGUF/safetensors parity).
logits_processors = []
if repetition_penalty is not None and float(repetition_penalty) not in (
0.0,
1.0,
):
logits_processors = make_logits_processors(
repetition_penalty = float(repetition_penalty),
logits_processors.extend(
make_logits_processors(
repetition_penalty = float(repetition_penalty),
)
)
if presence_penalty:
logits_processors.append(_make_mlx_presence_penalty_processor(float(presence_penalty)))
if not logits_processors:
logits_processors = None
token_ids = []
logger.info(
@ -410,12 +576,11 @@ class MLXInferenceBackend:
):
final_response = response
token_ids.append(response.token)
# Decode full sequence with skip_special_tokens
cumulative = self._tokenizer.decode(
token_ids,
skip_special_tokens = True,
)
yield cumulative
yield think_prefix + cumulative
if cancel_event and cancel_event.is_set():
break
@ -449,6 +614,7 @@ class MLXInferenceBackend:
enable_thinking = None,
reasoning_effort = None,
preserve_thinking = None,
presence_penalty = 0.0,
):
from mlx_vlm import stream_generate as vlm_stream
@ -457,8 +623,7 @@ class MLXInferenceBackend:
)
# Pick the chat-template-aware caller: processors with their own
# apply_chat_template + chat_template (e.g. Qwen2.5-VL) use it
# directly; else fall back to the nested tokenizer.
# apply_chat_template + chat_template (e.g. Qwen2.5-VL), else the nested tokenizer.
chat_target = self._processor
if (
getattr(self._processor, "apply_chat_template", None) is None
@ -479,16 +644,21 @@ class MLXInferenceBackend:
# mlx_vlm's stream_generate handles pixel_values (None for text-only)
images = [image] if image is not None else None
cumulative = ""
from core.inference.chat_template_helpers import detect_think_prefill
# Re-emit an open <think> prefill from the prompt (see _generate_text).
cumulative = detect_think_prefill(prompt, getattr(chat_target, "all_special_tokens", None))
# Emit it before the first token so the block renders during prefill.
if cumulative:
yield cumulative
logger.info(
"VLM generating: prompt_len=%d, has_image=%s",
len(prompt),
image is not None,
)
# mlx_vlm.stream_generate forwards **kwargs into generate_step, which
# builds the sampler + logits_processors internally.
# GOTCHA: generate_step expects ``temperature=`` (long form); ``temp=``
# silently falls into **kwargs and is ignored, stuck at greedy 0.0.
# stream_generate forwards **kwargs into generate_step (builds the
# sampler + logits_processors internally). GOTCHA: generate_step expects
# temperature= (long form); temp= is silently ignored, stuck at greedy 0.0.
vlm_kwargs = dict(
max_tokens = max_new_tokens,
temperature = temperature,
@ -496,10 +666,23 @@ class MLXInferenceBackend:
top_k = int(top_k or 0),
min_p = float(min_p or 0.0),
)
if repetition_penalty is not None and float(repetition_penalty) not in (
_rep_active = repetition_penalty is not None and float(repetition_penalty) not in (
0.0,
1.0,
):
)
if presence_penalty:
# Presence needs a custom processor: pass the full list (repetition +
# presence) instead of the repetition_penalty shortcut so both apply.
from mlx_lm.sample_utils import make_logits_processors
_vlm_processors = []
if _rep_active:
_vlm_processors.extend(
make_logits_processors(repetition_penalty = float(repetition_penalty))
)
_vlm_processors.append(_make_mlx_presence_penalty_processor(float(presence_penalty)))
vlm_kwargs["logits_processors"] = _vlm_processors
elif _rep_active:
vlm_kwargs["repetition_penalty"] = float(repetition_penalty)
with self._generation_lock:
@ -534,7 +717,7 @@ class MLXInferenceBackend:
cancel_event = None,
**gen_kwargs,
) -> Generator[str, None, None]:
# MLX LoRA adapter toggling not yet supported generate normally
# MLX LoRA adapter toggling not yet supported; generate normally
yield from self.generate_chat_response(cancel_event = cancel_event, **gen_kwargs)
def reset_generation_state(self):

View file

@ -45,6 +45,22 @@ _DISPATCH_STOP_TIMEOUT = 5.0
_DISPATCH_IDLE_TIMEOUT = 30.0
_DISPATCH_DRAIN_TIMEOUT = 5.0
# Max wait for a cancelled generation to release _gen_lock before unload_model
# tears the subprocess down. Only bounds a wedged worker.
_UNLOAD_GEN_LOCK_TIMEOUT = 15.0
class GenStreamError(str):
"""A stream chunk carrying a real backend/generation error, not model text.
Subclasses str so existing display/logging consumers are unaffected, while
callers that must abort a distributed run on error (raise_on_streamed_error)
can distinguish a real error from model output whose visible text starts with
"Error:" by checking isinstance(chunk, GenStreamError).
"""
__slots__ = ()
class InferenceOrchestrator:
"""
@ -60,7 +76,13 @@ class InferenceOrchestrator:
self._cmd_queue: Any = None
self._resp_queue: Any = None
self._cancel_event: Any = None # mp.Event — set to cancel generation
# Set for the whole unload; the worker never clears it (unlike _cancel_event),
# so a generate queued behind the cancelled one is skipped, not run.
self._drain_event: Any = None
self._gen_lock = threading.Lock() # Serializes generation
# Set during a switch so a generation winning the _gen_lock handoff bails
# instead of starting on the outgoing model.
self._unload_pending = False
# Dispatcher state for compare mode (adapter-controlled requests):
# bypass _gen_lock, send commands directly, read from per-request
@ -69,6 +91,12 @@ class InferenceOrchestrator:
self._mailbox_lock = threading.Lock()
self._dispatcher_thread: Optional[threading.Thread] = None
self._dispatcher_stop = threading.Event()
# Serializes dispatcher start/stop. _generate_dispatched (compare mode) bypasses
# _gen_lock, so two concurrent compare requests can both reach _start_dispatcher;
# without this lock both could observe no live dispatcher and each spawn one,
# orphaning the extra thread (self._dispatcher_thread tracks only the last). The
# orphan later steals the "unloaded" reply off resp_queue and hangs unload_model.
self._dispatcher_lifecycle_lock = threading.Lock()
# Local state mirrors (updated from subprocess responses)
self.active_model_name: Optional[str] = None
@ -92,13 +120,11 @@ class InferenceOrchestrator:
@property
def default_models(self) -> list[str]:
# Wait up to 5s for background HF fetch
self._top_models_ready.wait(timeout = 5)
top_gguf = self._top_gguf_cache or []
top_hub = self._top_hub_cache or []
# Curated static defaults first, then HF download-ranked to backfill.
# Send extras so the frontend keeps 4 per category after removing
# downloaded ones.
# Never wait for the remote Hugging Face ranking during startup. Chat's
# first /api/models/list needs curated defaults immediately; the
# background fetch backfills extra choices on later calls.
result: list[str] = []
seen: set[str] = set()
for m in self._static_models + top_gguf + top_hub:
@ -159,6 +185,7 @@ class InferenceOrchestrator:
self._cmd_queue = _CTX.Queue()
self._resp_queue = _CTX.Queue()
self._cancel_event = _CTX.Event()
self._drain_event = _CTX.Event()
self._proc = _CTX.Process(
target = run_without_native_path_secret,
@ -167,6 +194,7 @@ class InferenceOrchestrator:
"cmd_queue": self._cmd_queue,
"resp_queue": self._resp_queue,
"cancel_event": self._cancel_event,
"drain_event": self._drain_event,
"config": config,
},
daemon = True,
@ -228,6 +256,7 @@ class InferenceOrchestrator:
self._cmd_queue = None
self._resp_queue = None
self._cancel_event = None
self._drain_event = None
logger.info("Inference subprocess shut down")
def _cleanup(self):
@ -409,6 +438,7 @@ class InferenceOrchestrator:
enable_thinking: Optional[bool] = None,
reasoning_effort: Optional[str] = None,
preserve_thinking: Optional[bool] = None,
presence_penalty: float = 0.0,
) -> dict:
"""Build the 'generate' command shared by the locked and dispatched paths."""
cmd = {
@ -423,6 +453,7 @@ class InferenceOrchestrator:
"min_p": min_p,
"max_new_tokens": max_new_tokens,
"repetition_penalty": repetition_penalty,
"presence_penalty": presence_penalty,
}
# Only forward template kwargs the caller set, for older worker compat.
if use_adapter is not None:
@ -456,12 +487,20 @@ class InferenceOrchestrator:
cancel ack from that same source so stale events don't leak into the
next request.
"""
# Latch this stream's subprocess/queue: if a wedged worker is torn down and a
# later load spawns a fresh one, bail rather than re-block on the new queue
# under _gen_lock (deadlock).
initial_proc = self._proc
initial_resp_queue = self._resp_queue
while True:
if self._proc is not initial_proc or self._resp_queue is not initial_resp_queue:
yield GenStreamError(f"Error: {self._subprocess_crash_message(crash_context)}")
return
resp = read_one(read_timeout)
if resp is None:
# Check subprocess health
if not self._ensure_subprocess_alive():
yield f"Error: {self._subprocess_crash_message(crash_context)}"
yield GenStreamError(f"Error: {self._subprocess_crash_message(crash_context)}")
return
continue
@ -471,7 +510,7 @@ class InferenceOrchestrator:
# Subprocess-level error (no request_id); request-scoped failures
# arrive as gen_error below.
if rtype == "error" and not resp.get("request_id"):
yield f"Error: {resp.get('error', 'Unknown error')}"
yield GenStreamError(f"Error: {resp.get('error', 'Unknown error')}")
return
if rtype == "token":
@ -486,40 +525,63 @@ class InferenceOrchestrator:
stats_holder["stats"] = resp.get("stats")
return
elif rtype == "gen_error":
yield f"Error: {resp.get('error', 'Unknown error')}"
yield GenStreamError(f"Error: {resp.get('error', 'Unknown error')}")
return
# ------------------------------------------------------------------
# Dispatcher — per-request mailbox routing for compare mode
# ------------------------------------------------------------------
def _start_dispatcher(self) -> None:
def _start_dispatcher(self) -> bool:
"""Start the dispatcher thread if not already running.
The dispatcher reads the shared resp_queue and routes responses to
per-request mailbox queues, letting multiple adapter-controlled
(compare) requests be in-flight without holding _gen_lock.
"""
if self._dispatcher_thread is not None and self._dispatcher_thread.is_alive():
return
self._dispatcher_stop.clear()
self._dispatcher_thread = threading.Thread(
target = self._dispatcher_loop,
daemon = True,
name = "inference-dispatcher",
)
self._dispatcher_thread.start()
logger.debug("Dispatcher thread started")
The whole check-then-spawn runs under _dispatcher_lifecycle_lock so
concurrent compare requests (which bypass _gen_lock) can't both observe
no live dispatcher and each spawn one. Returns True only for the caller
that actually started a new thread; False if one was already alive.
"""
with self._dispatcher_lifecycle_lock:
# Refuse to start while an unload is in progress. unload_model sets
# _unload_pending under this same lock before it stops the idle
# dispatcher, so a start queued behind that stop observes the unload
# here and bails. Without this a fresh dispatcher would be spawned
# after the stop, become the resp_queue reader, and consume the
# worker's "unloaded" reply (unroutable, so dropped) before
# unload_model's _wait_response sees it -- hanging the unload 300s.
if self._unload_pending:
return False
if self._dispatcher_thread is not None and self._dispatcher_thread.is_alive():
return False
self._dispatcher_stop.clear()
self._dispatcher_thread = threading.Thread(
target = self._dispatcher_loop,
daemon = True,
name = "inference-dispatcher",
)
self._dispatcher_thread.start()
logger.debug("Dispatcher thread started")
return True
def _stop_dispatcher(self) -> None:
"""Signal the dispatcher to stop and wait for it."""
if self._dispatcher_thread is None:
return
self._dispatcher_stop.set()
self._dispatcher_thread.join(timeout = _DISPATCH_STOP_TIMEOUT)
self._dispatcher_thread = None
logger.debug("Dispatcher thread stopped")
"""Signal the dispatcher to stop and wait for it.
Runs under _dispatcher_lifecycle_lock (paired with _start_dispatcher) so
a stop can't interleave with a concurrent start. Callers must NOT hold
_mailbox_lock here: this joins the dispatcher, and the dispatcher loop
takes _mailbox_lock, so holding it would deadlock the join.
"""
with self._dispatcher_lifecycle_lock:
if self._dispatcher_thread is None:
return
self._dispatcher_stop.set()
self._dispatcher_thread.join(timeout = _DISPATCH_STOP_TIMEOUT)
self._dispatcher_thread = None
logger.debug("Dispatcher thread stopped")
def _dispatcher_loop(self) -> None:
"""Background loop: read resp_queue → route to mailboxes by request_id."""
@ -581,6 +643,7 @@ class InferenceOrchestrator:
reasoning_effort: Optional[str] = None,
preserve_thinking: Optional[bool] = None,
stats_holder: Optional[dict] = None,
presence_penalty: float = 0.0,
) -> Generator[str, None, None]:
"""Dispatched generation — sends command without holding _gen_lock.
@ -589,15 +652,32 @@ class InferenceOrchestrator:
GPU work stays serialized; this only avoids orchestrator lock contention.
"""
if not self._ensure_subprocess_alive():
yield "Error: Inference subprocess is not running"
yield GenStreamError("Error: Inference subprocess is not running")
return
if not self.active_model_name:
yield "Error: No active model"
yield GenStreamError("Error: No active model")
return
# Latch the target model so the recheck below can detect a switch that completed
# between _start_dispatcher and mailbox registration (mirrors the locked path's
# expected_model check).
expected_model = self.active_model_name
# Switch in flight (unload waiting on _gen_lock). This path bypasses the lock,
# so without this early-out a compare request would enqueue a generate on the
# outgoing model and delay the switch.
if self._unload_pending:
yield GenStreamError("Error: model is being unloaded")
return
# Ensure dispatcher is running
self._start_dispatcher()
# Ensure the dispatcher runs. _start_dispatcher serializes concurrent starters under
# _dispatcher_lifecycle_lock and returns True only for the caller that actually spawned
# the thread, so at most one dispatcher ever exists even when two compare requests race
# here. Derive dispatcher_preexisting from that atomic result (not a separate unlocked
# is_alive() read): if THIS call started the dispatcher and then bails on a racing
# unload, it must stop it again (see the unloading bail below).
started = self._start_dispatcher()
dispatcher_preexisting = not started
request_id = str(uuid.uuid4())
@ -617,6 +697,7 @@ class InferenceOrchestrator:
min_p = min_p,
max_new_tokens = max_new_tokens,
repetition_penalty = repetition_penalty,
presence_penalty = presence_penalty,
use_adapter = use_adapter,
tools = tools,
enable_thinking = enable_thinking,
@ -624,17 +705,49 @@ class InferenceOrchestrator:
preserve_thinking = preserve_thinking,
)
# Create mailbox BEFORE sending command
# Create the mailbox BEFORE sending, rechecking _unload_pending under
# _mailbox_lock: an unload sets _unload_pending before _wait_dispatcher_idle
# reads _mailboxes under the same lock, so either the idle check sees this
# mailbox (and tears the dispatcher down) or we see the unload and bail.
# Registering after would orphan the mailbox and hang the compare stream forever.
mailbox: queue.Queue = queue.Queue()
with self._mailbox_lock:
self._mailboxes[request_id] = mailbox
# _unload_pending alone is not enough: an unload that ran fully since
# _start_dispatcher clears it in its finally and stops the dispatcher, so it
# reads False here though the dispatcher is gone and the model swapped. Also
# bail when the active model changed or the dispatcher died: a mailbox with no
# dispatcher to route gen_done/gen_error hangs the compare stream.
dispatcher_alive = (
self._dispatcher_thread is not None and self._dispatcher_thread.is_alive()
)
unloading = (
self._unload_pending
or self.active_model_name != expected_model
or not dispatcher_alive
)
if not unloading:
self._mailboxes[request_id] = mailbox
# When bailing without a mailbox, note whether any OTHER compare request still
# routes through the dispatcher; if none and this call started it, stop it below.
orphaned_dispatcher = unloading and not dispatcher_preexisting and not self._mailboxes
if unloading:
# A racing unload can pass its _wait_dispatcher_idle() while the dispatcher was
# stopped, then set _unload_pending. The one we just started would otherwise
# linger with no mailboxes, race unload_model's _wait_response for the "unloaded"
# reply off resp_queue, and drop it as unroutable -- hanging the unload 300s. Stop
# it here so the unload stays the sole resp_queue reader. Outside _mailbox_lock:
# _stop_dispatcher joins the dispatcher, which itself takes that lock.
if orphaned_dispatcher:
self._stop_dispatcher()
yield GenStreamError("Error: model is being unloaded")
return
try:
self._send_cmd(cmd)
except RuntimeError as exc:
with self._mailbox_lock:
self._mailboxes.pop(request_id, None)
yield f"Error: {exc}"
yield GenStreamError(f"Error: {exc}")
return
def read_mailbox(timeout):
@ -676,14 +789,18 @@ class InferenceOrchestrator:
return
logger.warning("Timed out draining mailbox after cancel")
def _wait_dispatcher_idle(self) -> None:
def _wait_dispatcher_idle(self) -> bool:
"""Wait for all dispatched requests to complete, then stop dispatcher.
Called by _generate_inner before the _gen_lock path so the dispatcher
thread isn't competing for resp_queue reads.
Returns True if the dispatcher was stopped (all mailboxes drained, or no
dispatcher was running), and False if it was left running because compare
requests were still active after _DISPATCH_IDLE_TIMEOUT.
Called before the _gen_lock path so the dispatcher thread isn't competing
for resp_queue reads.
"""
if self._dispatcher_thread is None or not self._dispatcher_thread.is_alive():
return
return True
# Wait for all mailboxes to be emptied (dispatched requests complete)
deadline = time.monotonic() + _DISPATCH_IDLE_TIMEOUT
@ -704,8 +821,62 @@ class InferenceOrchestrator:
"leaving dispatcher running for compare requests",
len(self._mailboxes),
)
else:
self._stop_dispatcher()
return False
self._stop_dispatcher()
return True
def share_distributed_object(
self,
obj,
timeout: Optional[float] = 300.0,
):
"""Share a small object through the worker's MLX distributed group."""
if not self._ensure_subprocess_alive():
raise RuntimeError("Inference subprocess is not running")
self._wait_dispatcher_idle()
with self._mailbox_lock:
if self._mailboxes:
raise RuntimeError(
"Cannot share distributed objects while compare requests are active"
)
request_id = str(uuid.uuid4())
cmd = {
"type": "share_object",
"request_id": request_id,
"object": obj,
}
with self._gen_lock:
self._send_cmd(cmd)
deadline = None if timeout is None else time.monotonic() + timeout
while deadline is None or time.monotonic() < deadline:
remaining = 1.0 if deadline is None else max(0.1, deadline - time.monotonic())
resp = self._read_resp(timeout = min(remaining, 1.0))
if resp is None:
if not self._ensure_subprocess_alive():
raise RuntimeError(self._subprocess_crash_message("sharing chat turn"))
continue
rtype = resp.get("type", "")
rid = resp.get("request_id")
if rid and rid != request_id:
logger.debug(
"Skipping response for request_id=%s while sharing request_id=%s",
rid,
request_id,
)
continue
if rtype == "shared":
return resp.get("object")
if rtype == "share_error":
raise RuntimeError(resp.get("error", "Failed to share object"))
if rtype == "error":
raise RuntimeError(resp.get("error", "Subprocess error"))
if rtype == "status":
continue
raise RuntimeError("Timeout waiting for distributed object share")
# ------------------------------------------------------------------
# Public API — same interface as InferenceBackend
@ -722,6 +893,8 @@ class InferenceOrchestrator:
approved_remote_code_fingerprint: Optional[str] = None,
gpu_ids: Optional[list[int]] = None,
subject: Optional[str] = None,
tensor_parallel: bool = False,
mlx_distributed: bool = False,
) -> bool:
"""Load a model for inference.
@ -747,6 +920,11 @@ class InferenceOrchestrator:
"approved_remote_code_fingerprint": approved_remote_code_fingerprint,
"subject": subject,
"gpu_ids": gpu_ids,
"tensor_parallel": bool(tensor_parallel),
"mlx_distributed": bool(mlx_distributed),
"mlx_parallel_mode": ("tensor" if tensor_parallel else "pipeline")
if mlx_distributed
else None,
}
resolved_gpu_ids, gpu_selection = prepare_gpu_selection(
gpu_ids,
@ -772,6 +950,19 @@ class InferenceOrchestrator:
)
for attempt in range(2):
# Stop-loading (/unload -> cancel_load) aborts a load by discarding this
# model's loading marker. cancel_load only kills a live child; if the cancel
# lands before any child exists (GPU placement, or between retries) there is
# nothing to kill, and without this check the loop would spawn a worker and
# load the model after /unload reported it unloaded. Observe removal and stop.
if model_name not in self.loading_models:
logger.info(
"Load for '%s' was cancelled before spawn; not starting a worker",
model_name,
)
self.active_model_name = None
self.models.clear()
return False
logger.info(
"Spawning fresh inference subprocess for '%s' "
"(transformers %s.x, attempt %d/2%s)",
@ -783,6 +974,22 @@ class InferenceOrchestrator:
sub_config["disable_xet"] = disable_xet
self._spawn_subprocess(sub_config)
# A cancel can land after the pre-spawn recheck but while _spawn_subprocess
# is still creating the queues/process. cancel_load runs off the lifecycle
# gate, so its _shutdown_subprocess can see _proc still None and no-op,
# orphaning this fresh worker; the load would then wait for "loaded" and
# publish a model /unload reported unloaded, over a live subprocess nothing
# reaps. Recheck now the child exists and tear it down before publishing.
if model_name not in self.loading_models:
logger.info(
"Load for '%s' was cancelled during spawn; tearing the worker down",
model_name,
)
self._shutdown_subprocess(timeout = 5)
self.active_model_name = None
self.models.clear()
return False
try:
resp = self._wait_response("loaded")
except DownloadStallError:
@ -803,8 +1010,31 @@ class InferenceOrchestrator:
)
if resp.get("success"):
# A cancel can land while we were parked in _wait_response above.
# cancel_load (off the lifecycle gate) discards this model's loading
# marker BEFORE its teardown, so a Stop-loading that fired after the
# worker queued "loaded" (which we can still consume during cancel_load's
# shutdown window) shows up here only as the marker's removal. Without
# this recheck we would publish active_model_name/models for a model
# /unload reported cancelled, over a subprocess cancel_load just killed;
# its post-teardown re-clear cannot undo a publish that lands after it
# returns. Observe the removal and abort; cancel_load owns teardown.
if model_name not in self.loading_models:
logger.info(
"Load for '%s' was cancelled while waiting for 'loaded'; "
"not publishing the cancelled model",
model_name,
)
self.active_model_name = None
self.models.clear()
return False
model_info = resp.get("model_info", {})
self.active_model_name = model_info.get("identifier", model_name)
# A load always spawns a fresh subprocess holding only this model, so
# mirror that. A lingering stale name would pass unload_model's "not in
# self.models" guard, and the worker's absent-name fallback would unload
# its *active* model, not the already-gone one.
self.models = {}
self.models[self.active_model_name] = {
"is_vision": model_info.get("is_vision", False),
"is_lora": model_info.get("is_lora", False),
@ -837,17 +1067,65 @@ class InferenceOrchestrator:
self.models.clear()
raise
def unload_model(self, model_name: str) -> bool:
"""Unload a model from the subprocess."""
if model_name in self.loading_models:
logger.info(
"Cancelling in-flight load for model '%s' by terminating subprocess",
def cancel_load(self, model_name: str) -> bool:
"""Abort an in-flight load by terminating its subprocess.
Returns True if a load for ``model_name`` (matched case-insensitively) was
cancelled, False if nothing was loading under that name. This only tears the
loading subprocess down -- it sends no command to a worker -- so, unlike the
rest of ``unload_model``, it is safe to run WITHOUT the inference lifecycle
gate. ``/unload`` calls it off-gate so the "stop loading" button can interrupt
a safetensors load that holds the gate for its whole (multi-minute) duration;
a gated cancel could never preempt that load.
"""
target = model_name
if target not in self.loading_models:
target = next(
(m for m in self.loading_models if m.lower() == model_name.lower()),
model_name,
)
self._shutdown_subprocess(timeout = 0.5)
self.loading_models.discard(model_name)
self.active_model_name = None
self.models.clear()
if target not in self.loading_models:
return False
logger.info(
"Cancelling in-flight load for model '%s' by terminating subprocess",
target,
)
# Discard the loading marker (and clear local state) BEFORE the teardown, not
# after. cancel_load runs off the lifecycle gate, alongside a load_model that
# rechecks this marker before each spawn. But _shutdown_subprocess can block (~1s
# tearing a live child down and joining the dispatcher), so clearing only after
# leaves a window where load_model reads the marker still set, passes its pre-spawn
# recheck, and loads the model after /unload reported it cancelled. Clear first.
self.loading_models.discard(target)
self.active_model_name = None
self.models.clear()
self._shutdown_subprocess(timeout = 0.5)
# Clear the local mirrors again AFTER the teardown. A racing off-gate load_model
# may still be parked in _wait_response("loaded"): its worker already queued a
# "loaded" reply, so during the shutdown window above (the 0.5s settle before the
# response queue is drained and nulled) that thread can consume it and repopulate
# active_model_name/models, undoing the pre-teardown clear. _shutdown_subprocess
# nulls the queue but not the mirrors, so without this second clear /unload reports
# success while the backend still advertises a killed model. The nulled queue lets
# no further "loaded" through, so re-clearing here wipes any repopulation.
self.active_model_name = None
self.models.clear()
return True
def unload_model(self, model_name: str) -> bool:
"""Unload a model from the subprocess."""
# active_model_name can differ in case from the client's raw /unload name (the
# load path canonicalizes casing). Match case-insensitively and use the canonical
# spelling so the guard, unload command, and cleanup below hit the loaded model.
if (
self.active_model_name is not None
and model_name != self.active_model_name
and model_name.lower() == self.active_model_name.lower()
):
model_name = self.active_model_name
# In-flight load: tear its subprocess down (shared loading-cancel logic; no
# worker command sent).
if self.cancel_load(model_name):
return True
if not self._ensure_subprocess_alive():
@ -857,30 +1135,93 @@ class InferenceOrchestrator:
self.active_model_name = None
return True
try:
self._send_cmd(
{
"type": "unload",
"model_name": model_name,
}
)
resp = self._wait_response("unloaded")
# Update local state
# Nothing loaded under this name: don't unload a stale model. The worker falls
# back to unloading its *active* model when the name is absent, so a stale unload
# (lost a race to a concurrent load) would hit the wrong one.
if model_name != self.active_model_name and model_name not in self.models:
self.models.pop(model_name, None)
if self.active_model_name == model_name:
self.active_model_name = None
logger.info("Model '%s' unloaded from subprocess", model_name)
return True
except Exception as exc:
logger.error("Error unloading model '%s': %s", model_name, exc)
# Clear local state anyway
self.models.pop(model_name, None)
if self.active_model_name == model_name:
self.active_model_name = None
return False
# The subprocess runs commands sequentially, so a bare unload queues behind a
# running generate (a 2-3 min hang). Cancel first (via the mp.Event the worker
# polls each token), then take _gen_lock as sole resp_queue reader (like GGUF).
#
# Set _unload_pending under _dispatcher_lifecycle_lock so it is ordered ahead of
# the dispatcher stop that _wait_dispatcher_idle runs under the same lock: a
# compare request's _start_dispatcher queued behind that stop then observes the
# unload and refuses to spawn a fresh dispatcher that would eat the "unloaded"
# reply off resp_queue. This is a standalone acquisition (no _gen_lock held yet),
# so it keeps the _gen_lock -> _dispatcher_lifecycle_lock order and can't deadlock.
with self._dispatcher_lifecycle_lock:
self._unload_pending = True
# Cancelling only the running generation isn't enough: the worker clears
# cancel_event at each generate start, so a queued one would clear it and run the
# outgoing model to completion. drain_event, never cleared, makes any generate
# dequeued during the unload skip.
if self._drain_event is not None:
self._drain_event.set()
try:
self._cancel_generation()
acquired = self._gen_lock.acquire(timeout = _UNLOAD_GEN_LOCK_TIMEOUT)
if not acquired:
# Wedged worker: tear the subprocess down to free the GPU (next load respawns).
logger.warning(
"Unload: generation did not yield %.1fs after cancel; "
"shutting the inference subprocess down to free the model",
_UNLOAD_GEN_LOCK_TIMEOUT,
)
self._shutdown_subprocess(timeout = 5)
self.models.pop(model_name, None)
if self.active_model_name == model_name:
self.active_model_name = None
return True
try:
# Stop the compare-mode dispatcher so it can't consume the "unloaded" reply
# off resp_queue before we do. A dispatched generation bypasses _gen_lock, so
# a wedged one slips past the acquire above; if the dispatcher is still active
# it owns resp_queue and the queued unload hangs _wait_response behind the
# stuck generate. Mirror the wedged locked path: tear the subprocess down.
if not self._wait_dispatcher_idle():
logger.warning(
"Unload: compare-mode dispatcher still active after idle "
"wait; shutting the inference subprocess down to free the model"
)
self._shutdown_subprocess(timeout = 5)
self.models.pop(model_name, None)
if self.active_model_name == model_name:
self.active_model_name = None
return True
# Drop stale tokens so they can't be read as the unload reply.
self._drain_queue()
self._send_cmd(
{
"type": "unload",
"model_name": model_name,
}
)
self._wait_response("unloaded")
self.models.pop(model_name, None)
if self.active_model_name == model_name:
self.active_model_name = None
logger.info("Model '%s' unloaded from subprocess", model_name)
return True
except Exception as exc:
logger.error("Error unloading model '%s': %s", model_name, exc)
# Clear local state anyway
self.models.pop(model_name, None)
if self.active_model_name == model_name:
self.active_model_name = None
return False
finally:
self._gen_lock.release()
finally:
self._unload_pending = False
if self._drain_event is not None:
self._drain_event.clear()
def generate_chat_response(
self,
@ -899,6 +1240,7 @@ class InferenceOrchestrator:
reasoning_effort: Optional[str] = None,
preserve_thinking: Optional[bool] = None,
stats_holder: Optional[dict] = None,
presence_penalty: float = 0.0,
) -> Generator[str, None, None]:
"""Generate response, streaming tokens from subprocess.
@ -908,6 +1250,8 @@ class InferenceOrchestrator:
``stats_holder``: caller-owned dict; on gen_done its "stats" key gets
the worker's usage/timings. Request-scoped to avoid cross-stream reads.
``presence_penalty`` matches the GGUF sampling path (0 disables it).
"""
yield from self._generate_inner(
messages = messages,
@ -926,6 +1270,7 @@ class InferenceOrchestrator:
reasoning_effort = reasoning_effort,
preserve_thinking = preserve_thinking,
stats_holder = stats_holder,
presence_penalty = presence_penalty,
)
def generate_chat_completion_with_tools(
@ -945,6 +1290,7 @@ class InferenceOrchestrator:
preserve_thinking: Optional[bool] = None,
max_tool_iterations: int = 25,
auto_heal_tool_calls: bool = True,
nudge_tool_calls: Optional[bool] = None,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
rag_scope: Optional[dict] = None,
@ -952,6 +1298,7 @@ class InferenceOrchestrator:
bypass_permissions: bool = False,
use_adapter: Optional[Union[bool, str]] = None,
stats_holder: Optional[dict] = None,
presence_penalty: float = 0.0,
**_unused,
):
"""Run the safetensors agentic tool loop in the parent process,
@ -987,6 +1334,7 @@ class InferenceOrchestrator:
preserve_thinking = preserve_thinking,
# last turn wins, like the GGUF tool loop
stats_holder = stats_holder,
presence_penalty = presence_penalty,
)
if use_adapter is not None:
yield from self.generate_with_adapter_control(
@ -1007,6 +1355,7 @@ class InferenceOrchestrator:
execute_tool = execute_tool,
cancel_event = cancel_event,
auto_heal_tool_calls = auto_heal_tool_calls,
nudge_tool_calls = nudge_tool_calls,
max_tool_iterations = max_tool_iterations,
tool_call_timeout = tool_call_timeout,
session_id = session_id,
@ -1053,6 +1402,7 @@ class InferenceOrchestrator:
reasoning_effort: Optional[str] = None,
preserve_thinking: Optional[bool] = None,
stats_holder: Optional[dict] = None,
presence_penalty: float = 0.0,
) -> Generator[str, None, None]:
"""Inner generation logic — sends command to subprocess, yields tokens.
@ -1060,12 +1410,13 @@ class InferenceOrchestrator:
readers don't consume each other's tokens off the shared resp_queue.
"""
if not self._ensure_subprocess_alive():
yield "Error: Inference subprocess is not running"
yield GenStreamError("Error: Inference subprocess is not running")
return
if not self.active_model_name:
yield "Error: No active model"
yield GenStreamError("Error: No active model")
return
expected_model = self.active_model_name
# Drain any prior compare-mode dispatcher so we can read resp_queue.
self._wait_dispatcher_idle()
@ -1074,6 +1425,14 @@ class InferenceOrchestrator:
# consume and drop each other's token events. Hold _gen_lock across the
# cmd build + send + whole stream so we stay the sole resp_queue reader.
with self._gen_lock:
# Recheck under the lock: an unload we raced may have cleared/swapped the model.
# _unload_pending resets after the lock releases, so it can read False by now;
# the active-model check catches that handoff and a reload that swapped models,
# so we never generate on the wrong one.
if self._unload_pending or self.active_model_name != expected_model:
# Won the lock handoff during a switch; don't start on the outgoing model.
yield GenStreamError("Error: model is being unloaded")
return
request_id = str(uuid.uuid4())
image_b64 = self._pil_to_base64(image) if image is not None else None
cmd = self._build_generate_cmd(
@ -1087,6 +1446,7 @@ class InferenceOrchestrator:
min_p = min_p,
max_new_tokens = max_new_tokens,
repetition_penalty = repetition_penalty,
presence_penalty = presence_penalty,
use_adapter = use_adapter,
tools = tools,
enable_thinking = enable_thinking,
@ -1097,7 +1457,7 @@ class InferenceOrchestrator:
try:
self._send_cmd(cmd)
except RuntimeError as exc:
yield f"Error: {exc}"
yield GenStreamError(f"Error: {exc}")
return
yield from self._consume_token_stream(
@ -1141,53 +1501,62 @@ class InferenceOrchestrator:
raise RuntimeError("Inference subprocess is not running")
if not self.active_model_name:
raise RuntimeError("No active model")
expected_model = self.active_model_name
request_id = str(uuid.uuid4())
# Serialize under _gen_lock (sole resp_queue reader) and refuse to start on the
# outgoing model once an unload is pending, like the text and audio-input paths.
# Without this a concurrent /audio/generate could run TTS on a model being switched.
with self._gen_lock:
# Recheck under the lock (see _generate_inner): a raced unload/switch may have
# cleared or swapped the model while we waited.
if self._unload_pending or self.active_model_name != expected_model:
raise RuntimeError("model is being unloaded")
cmd = {
"type": "generate_audio",
"request_id": request_id,
"text": text,
"temperature": temperature,
"top_p": top_p,
"top_k": top_k,
"min_p": min_p,
"max_new_tokens": max_new_tokens,
"repetition_penalty": repetition_penalty,
}
if use_adapter is not None:
cmd["use_adapter"] = use_adapter
request_id = str(uuid.uuid4())
self._send_cmd(cmd)
cmd = {
"type": "generate_audio",
"request_id": request_id,
"text": text,
"temperature": temperature,
"top_p": top_p,
"top_k": top_k,
"min_p": min_p,
"max_new_tokens": max_new_tokens,
"repetition_penalty": repetition_penalty,
}
if use_adapter is not None:
cmd["use_adapter"] = use_adapter
# Wait for audio_done or audio_error
deadline = time.monotonic() + 120.0
while time.monotonic() < deadline:
remaining = max(0.1, deadline - time.monotonic())
resp = self._read_resp(timeout = min(remaining, 1.0))
self._send_cmd(cmd)
if resp is None:
if not self._ensure_subprocess_alive():
raise RuntimeError(self._subprocess_crash_message("audio generation"))
continue
deadline = time.monotonic() + 120.0
while time.monotonic() < deadline:
remaining = max(0.1, deadline - time.monotonic())
resp = self._read_resp(timeout = min(remaining, 1.0))
rtype = resp.get("type", "")
if resp is None:
if not self._ensure_subprocess_alive():
raise RuntimeError(self._subprocess_crash_message("audio generation"))
continue
if rtype == "audio_done":
wav_bytes = base64.b64decode(resp["wav_base64"])
sample_rate = resp["sample_rate"]
return wav_bytes, sample_rate
rtype = resp.get("type", "")
if rtype == "audio_error":
raise RuntimeError(resp.get("error", "Audio generation failed"))
if rtype == "audio_done":
wav_bytes = base64.b64decode(resp["wav_base64"])
sample_rate = resp["sample_rate"]
return wav_bytes, sample_rate
if rtype == "error":
raise RuntimeError(resp.get("error", "Unknown error"))
if rtype == "audio_error":
raise RuntimeError(resp.get("error", "Audio generation failed"))
if rtype == "status":
continue
if rtype == "error":
raise RuntimeError(resp.get("error", "Unknown error"))
raise RuntimeError("Timeout waiting for audio generation (120s)")
if rtype == "status":
continue
raise RuntimeError("Timeout waiting for audio generation (120s)")
def generate_whisper_response(
self,
@ -1247,13 +1616,20 @@ class InferenceOrchestrator:
) -> Generator[str, None, None]:
"""Shared inner logic for audio input generation (Whisper + ASR)."""
if not self._ensure_subprocess_alive():
yield "Error: Inference subprocess is not running"
yield GenStreamError("Error: Inference subprocess is not running")
return
if not self.active_model_name:
yield "Error: No active model"
yield GenStreamError("Error: No active model")
return
expected_model = self.active_model_name
with self._gen_lock:
# Recheck under the lock (see _generate_inner): a raced unload/switch may have
# cleared or swapped the model while we waited.
if self._unload_pending or self.active_model_name != expected_model:
# Won the lock handoff during a switch; don't start on the outgoing model.
yield GenStreamError("Error: model is being unloaded")
return
request_id = str(uuid.uuid4())
# numpy array -> list for mp.Queue serialization
@ -1279,7 +1655,7 @@ class InferenceOrchestrator:
try:
self._send_cmd(cmd)
except RuntimeError as exc:
yield f"Error: {exc}"
yield GenStreamError(f"Error: {exc}")
return
yield from self._consume_token_stream(

View file

@ -29,10 +29,25 @@ import os
from collections.abc import Mapping
from typing import Any, Optional
from core.inference.tool_call_parser import TOOL_XML_SIGNALS, has_tool_signal
from core.inference.tool_loop_controller import coerce_tool_arguments
from core.tool_healing import parse_tool_calls_from_text
# Only the formats this healer's parser can promote -- narrower than the loops'
# broader TOOL_XML_SIGNALS. A loop-only marker (Llama <|python_tag|>, bare
# [ARGS]) would buffer a streamed call as prose without promoting it, so keep a
# healer-aligned list. Mistral's [TOOL_CALLS] IS promotable, so it stays in.
_HEAL_SIGNALS = (
"<tool_call>",
"<|tool_call>",
"<function=",
"[TOOL_CALLS]",
)
def _has_heal_signal(text: str) -> bool:
return any(s in text for s in _HEAL_SIGNALS)
# Read once at import (same convention as the other UNSLOTH_* switches).
_HEALING_DISABLED = os.environ.get("UNSLOTH_DISABLE_TOOL_CALL_HEALING", "0") == "1"
# Nudging is OPT-IN: per-request nudge_tool_calls=true, or flip the process
@ -44,7 +59,7 @@ def nudge_enabled(request_flag: Optional[bool]) -> bool:
return _NUDGE_DEFAULT if request_flag is None else bool(request_flag)
_MAX_SIGNAL_LEN = max(len(s) for s in TOOL_XML_SIGNALS)
_MAX_SIGNAL_LEN = max(len(s) for s in _HEAL_SIGNALS)
# A suspected-but-unclosed tool block larger than this is declared a false
# alarm and flushed, bounding memory on a model rambling XML-lookalike text.
_MAX_HOLD_CHARS = 64 * 1024
@ -198,7 +213,7 @@ def heal_openai_message_events(
if not isinstance(msg, dict) or msg.get("tool_calls"):
return None
content = msg.get("content")
if not isinstance(content, str) or not has_tool_signal(content):
if not isinstance(content, str) or not _has_heal_signal(content):
return None
parsed, spans = parse_tool_calls_from_text(content, allow_incomplete = True, with_spans = True)
tool_schemas = _tool_schemas_by_name(tools) if tools is not None else None
@ -248,7 +263,7 @@ def heal_openai_message(
def _earliest_signal(buffer: str) -> int:
best = -1
for signal in TOOL_XML_SIGNALS:
for signal in _HEAL_SIGNALS:
index = buffer.find(signal)
if index >= 0 and (best < 0 or index < best):
best = index
@ -275,7 +290,7 @@ def _partial_signal_suffix(buffer: str) -> int:
"""Length of the longest buffer suffix that is a proper prefix of a signal."""
for length in range(min(len(buffer), _MAX_SIGNAL_LEN - 1), 0, -1):
tail = buffer[-length:]
if any(signal.startswith(tail) for signal in TOOL_XML_SIGNALS):
if any(signal.startswith(tail) for signal in _HEAL_SIGNALS):
return length
return 0
@ -340,9 +355,10 @@ class StreamToolCallHealer:
events.append(("text", emit))
self._buffer = self._buffer[len(self._buffer) - keep :]
return events
# HOLD: handle the FIRST complete block per pass so events keep
# document order (a later declared call must not overtake an
# earlier undeclared one flushing as text).
# HOLD: drain the first contiguous run per pass so events keep document
# order (a later declared call must not overtake an earlier undeclared one
# flushing as text). A run is one markup call OR a whole Mistral [TOOL_CALLS]
# array of contiguous spans, so later calls in it are not stranded as text.
parsed, spans = parse_tool_calls_from_text(
self._buffer,
id_offset = self._id_offset,
@ -363,26 +379,32 @@ class StreamToolCallHealer:
self._holding = False
continue
return events
start, end = spans[0]
promoted = _promote(
[parsed[0]],
self._allowed,
id_offset = self._id_offset,
tool_schemas = self._tool_schemas,
)
if promoted:
if start:
events.append(("text", self._buffer[:start]))
events.append(("tool_call", promoted[0]))
self._id_offset += 1
# Drop exactly the promoted markup span; everything else
# (leading text, later blocks) stays and is rescanned.
self._buffer = self._buffer[end:]
else:
# Undeclared or unusable name: its markup is DATA, flush it
# (and anything before it) verbatim, then rescan the rest.
events.append(("text", self._buffer[:end]))
self._buffer = self._buffer[end:]
pos = 0
run_end = spans[0][1]
for order, (call, (start, end)) in enumerate(zip(parsed, spans)):
# Stop at the first gap or incomplete trailing block: leave it for the
# next pass to re-hold and stream incrementally, not flush as text early.
if order and start != run_end:
break
promoted = _promote(
[call],
self._allowed,
id_offset = self._id_offset,
tool_schemas = self._tool_schemas,
)
if promoted:
# Flush any leading text, then drop the promoted markup span.
if self._buffer[pos:start]:
events.append(("text", self._buffer[pos:start]))
events.append(("tool_call", promoted[0]))
self._id_offset += 1
else:
# Undeclared/unusable name: markup is DATA, flush it (and prior text) verbatim.
events.append(("text", self._buffer[pos:end]))
pos = end
run_end = end
# Everything past the drained run (later blocks) stays and is rescanned.
self._buffer = self._buffer[run_end:]
self._holding = False
def finalize(self) -> list:
@ -508,7 +530,7 @@ def nudge_should_retry(
if not message or message.get("tool_calls"):
return False
text = message.get("content")
if not isinstance(text, str) or not has_tool_signal(text):
if not isinstance(text, str) or not _has_heal_signal(text):
return False
return not _heal_would_promote(text, allowed_tools, tools)

View file

@ -0,0 +1,49 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Presence-penalty logits helpers for the safetensors/MLX inference paths.
Kept in a dependency-light leaf module (torch + transformers only, no unsloth /
peft) so the pure logic can be imported and unit-tested without pulling in the
full inference backend. ``core.inference.inference`` re-exports these for the
runtime generate paths.
"""
import torch
def apply_presence_penalty(input_ids, scores, penalty: float, prompt_len: int):
"""OpenAI/llama.cpp presence penalty: subtract ``penalty`` once per distinct
completion token (positions >= prompt_len; prompt excluded, multiplicity
ignored, negatives raise). In place; zero is a no-op."""
if not penalty:
return scores
vocab_size = scores.shape[-1]
for b in range(input_ids.shape[0]):
generated = input_ids[b, prompt_len:]
if generated.numel() == 0:
continue
seen = torch.unique(generated)
# Bound generated ids to the valid range [0, vocab_size). Real completion
# tokens are always in range, so this is a zero-regression safety net that
# drops any stray out-of-range or negative id before indexing (mirrors the
# MLX path's bound). Filtering both ends avoids indexing scores with a
# negative id (which would silently wrap to the wrong row).
seen = seen[(seen >= 0) & (seen < vocab_size)]
if seen.numel():
scores[b, seen] = scores[b, seen] - penalty
return scores
def _make_presence_penalty_processor(penalty: float, prompt_len: int):
"""``LogitsProcessorList`` for ``apply_presence_penalty``; ``None`` at zero penalty (generate call stays byte-identical)."""
if not penalty:
return None
from transformers import LogitsProcessor, LogitsProcessorList
class _PresencePenaltyLogitsProcessor(LogitsProcessor):
@torch.no_grad()
def __call__(self, input_ids, scores):
return apply_presence_penalty(input_ids, scores, penalty, prompt_len)
return LogitsProcessorList([_PresencePenaltyLogitsProcessor()])

View file

@ -14,6 +14,7 @@ parses tool calls from the cumulative text and dispatches via
``core.inference.tools``.
"""
import bisect
import re
import threading
from typing import Callable, Generator, Optional
@ -21,14 +22,38 @@ from typing import Callable, Generator, Optional
from loggers import get_logger
from core.inference.tool_call_parser import (
_TOOL_ALL_PATS,
_GEMMA_BARE_TC_PREFIX_RE,
_GEMMA_BARE_TC_RE,
_TOOL_ALL_PATS as _PARSER_TOOL_ALL_PATS,
_TOOL_CLOSED_PATS as _PARSER_TOOL_CLOSED_PATS,
_balanced_brace_end,
_strip_function_xml_calls,
_strip_gemma_wrapperless_calls,
_strip_glm_calls,
_strip_mistral_closed_calls,
_strip_mistral_reasoning,
BUDGET_EXHAUSTED_NUDGE,
MAX_ACT_REPROMPTS,
RAG_MAX_SEARCHES_PER_TURN,
RAG_SEARCH_CAP_NUDGE,
TOOL_XML_SIGNALS,
is_short_intent_without_action,
parse_tool_calls_from_text,
reprompt_to_act_message,
strip_leading_bare_json_call,
strip_llama3_leading_sentinels,
strip_tool_markup,
)
# The healer owns the bracket-tag + rehearsal strip helpers and their name-gated
# pattern lists, so the safetensors streaming strip stays aligned with the parser.
from core.tool_healing import (
_REHEARSAL_TAIL_STRIP_RE,
_strip_bracket_tag_calls,
_think_spans_outside_tool_markup,
apply_tool_strip_patterns,
strip_outside_think,
)
from core.inference.tool_loop_controller import (
ToolLoopController,
coerce_tool_arguments,
@ -50,19 +75,213 @@ logger = get_logger(__name__)
# Buffer cap while disambiguating a possible tool-call prefix.
_MAX_BUFFER_CHARS = 32
# Memory bound for holding a leading bare-JSON object whose top-level "{" never balances.
_MAX_BARE_JSON_BUFFER = 16384
# No grammar constraint here (unlike llama-server's lazy grammar): collapse
# exact-duplicate calls and cap the count so a runaway turn cannot fan out.
_MAX_TOOL_CALLS_PER_TURN = 8
def _active_tool_names(active_tools: list[dict]) -> list[str]:
names = [
(tool.get("function") or {}).get("name")
for tool in active_tools
if isinstance(tool, dict) and isinstance(tool.get("function"), dict)
]
return [name for name in names if name]
def _active_tool_names(active_tools: list[dict]) -> list[str]:
names = [
(tool.get("function") or {}).get("name")
for tool in active_tools
if isinstance(tool, dict) and isinstance(tool.get("function"), dict)
]
return [name for name in names if name]
# Unrestricted mode has no tool list, so any identifier may open a NAME[ARGS] rehearsal;
# ``[`` and each ARGS letter stay optional so a chunk split after ``NAME[`` is still held.
_UNRESTRICTED_REHEARSAL_RE = re.compile(r"[\w-]+(?:\[(?:A(?:R(?:G(?:S)?)?)?)?)?")
def _is_rehearsal_prefix(
stripped: str,
active_tools: list[dict],
*,
unrestricted: bool = False,
) -> bool:
"""True if ``stripped`` is a (possibly partial) prefix of a ``NAME[ARGS]``
rehearsal split across chunks (``web_search`` then ``[ARGS]{...}``). A space
means prose. Unrestricted mode accepts any identifier; else NAME must be active."""
if not stripped or any(ch.isspace() for ch in stripped):
return False
if unrestricted:
return _UNRESTRICTED_REHEARSAL_RE.fullmatch(stripped) is not None
for name in _active_tool_names(active_tools):
if stripped == name or f"{name}[ARGS]".startswith(stripped):
return True
return False
def _held_rehearsal_tail_len(
text: str,
active_tools: list[dict],
*,
unrestricted: bool = False,
) -> int:
"""Length of a trailing bare tool-name token that may be a split rehearsal call
(``...web_search`` with ``[ARGS]{...}`` still to arrive), so STREAMING can hold it
instead of leaking the name. Returns 0 for ordinary prose."""
i = len(text)
while i > 0 and not text[i - 1].isspace():
i -= 1
tail = text[i:]
return (
len(tail)
if tail and _is_rehearsal_prefix(tail, active_tools, unrestricted = unrestricted)
else 0
)
def _rehearsal_name_start(
candidate: str,
signal_pos: int,
active_tools: list[dict],
*,
unrestricted: bool = False,
) -> int:
"""For an ``[ARGS]`` signal at ``signal_pos``, return the start of the preceding
bare tool-name token (``NAME[ARGS]``), else ``signal_pos`` unchanged when the
signal is not ``[ARGS]`` or NAME is not an active tool (restricted mode)."""
if not candidate.startswith("[ARGS]", signal_pos):
return signal_pos
j = signal_pos
while j > 0 and (candidate[j - 1].isalnum() or candidate[j - 1] in "_-"):
j -= 1
if j < signal_pos and (
unrestricted or candidate[j:signal_pos] in _active_tool_names(active_tools)
):
return j
return signal_pos
def _earliest_tool_signal(
candidate: str,
signals,
active_tools: list[dict],
*,
unrestricted: bool = False,
) -> int:
"""Index where the turn's first genuine tool-call boundary begins, or -1.
Non-``[ARGS]`` markup wins on first occurrence. An ``[ARGS]`` hit is a rehearsal
only when an active tool name (any name in unrestricted mode) precedes it, so a
literal ``foo[ARGS]`` in prose is skipped rather than draining the turn; for a
real ``NAME[ARGS]`` the boundary is pulled back to NAME."""
best = -1
for sig in signals:
if sig != "[ARGS]":
p = candidate.find(sig)
if p >= 0 and (best < 0 or p < best):
best = p
continue
from_idx = 0
while True:
p = candidate.find("[ARGS]", from_idx)
if p < 0:
break
name_start = _rehearsal_name_start(
candidate, p, active_tools, unrestricted = unrestricted
)
if name_start < p:
# Genuine ``NAME[ARGS]``: the boundary is the start of NAME.
if best < 0 or name_start < best:
best = name_start
break
# Bare/prose [ARGS]: skip it so a later real call in the same chunk is still found.
from_idx = p + len("[ARGS]")
return best
def _has_genuine_tool_signal(
candidate: str,
signals,
active_tools: list[dict],
*,
unrestricted: bool = False,
) -> bool:
"""True when ``candidate`` holds a genuine tool-call boundary for one of ``signals``.
Non-``[ARGS]`` markers count on a substring hit; an ``[ARGS]`` hit is genuine only
when an active tool name (any in unrestricted mode) precedes it. Mirrors the
``_earliest_tool_signal`` name-gating so BUFFERING / end-of-stream checks do not
drain inactive-name prose."""
for sig in signals:
if sig == "[ARGS]":
if (
_earliest_tool_signal(
candidate, ("[ARGS]",), active_tools, unrestricted = unrestricted
)
>= 0
):
return True
continue
if sig in candidate:
return True
return False
def strip_tool_markup_streaming(
text: str,
*,
auto_heal_tool_calls: bool = True,
tool_protocol_active: bool = False,
enabled_tool_names: Optional[set] = None,
) -> str:
"""Strip open-ended tool XML from display text without trimming whitespace."""
"""Strip open-ended tool XML from display text without trimming whitespace.
Mirrors the parser-side ``strip_tool_markup`` segment scan (minus the final trim) so
streaming and final display agree: balanced strips first (nested JSON removed whole),
then the guarded function-XML / GLM scans that close at each call's REAL terminator so
literal markup inside argument values is data and trailing prose survives. Reasoning
``<think>`` / ``[THINK]`` blocks are preserved verbatim (a rehearsed call inside one must
not be deleted, else the cumulative text shrinks then regrows). ``enabled_tool_names``
keeps an inactive-name ``foo[ARGS]{..}`` / ``call:NAME{..}`` example visible (it is prose,
not a call), matching the parse / detection active-tool gate."""
if not (auto_heal_tool_calls or tool_protocol_active):
return text
for pat in _TOOL_ALL_PATS:
text = pat.sub("", text)
return text
# Drop a leading Magistral ``[THINK]...[/THINK]`` block (bracket reasoning form, not the
# ``<think>`` channel) so raw reasoning does not leak into streamed display; an unclosed
# leading block is held (dropped to EOF) until its closer streams in.
text = _strip_mistral_reasoning(text)
def _seg(segment: str, is_last: bool) -> str:
# Same scan order as the parser's _strip_segment (seg_final -> is_last): balanced
# strips first, then the guarded function-XML / GLM scans, then the regex arms
# (DeepSeek / Kimi / closed forms). EOS-anchored tail arms run only on the last
# segment (a bare ``foo[ARGS]`` before <think> is prose). Rehearsal strips are name-gated.
seg = _strip_mistral_closed_calls(segment)
seg = _strip_bracket_tag_calls(seg, enabled_tool_names = enabled_tool_names)
if is_last:
seg = _strip_gemma_wrapperless_calls(seg, enabled_tool_names)
seg = _strip_function_xml_calls(seg, final = is_last)
seg = _strip_glm_calls(seg, final = is_last)
pats = _PARSER_TOOL_ALL_PATS if is_last else _PARSER_TOOL_CLOSED_PATS
for pat in pats:
seg = pat.sub("", seg)
if is_last:
seg = apply_tool_strip_patterns(
seg, [_REHEARSAL_TAIL_STRIP_RE], enabled_tool_names = enabled_tool_names
)
return seg
# Preserve think blocks verbatim: stripping a rehearsed call inside one shrinks then
# regrows the cumulative text, corrupting append-by-length consumers.
return strip_outside_think(text, _seg)
def _strip_tool_markup_final(
@ -70,10 +289,11 @@ def _strip_tool_markup_final(
*,
auto_heal_tool_calls: bool,
tool_protocol_active: bool = False,
enabled_tool_names: Optional[set] = None,
) -> str:
if not (auto_heal_tool_calls or tool_protocol_active):
return text
return strip_tool_markup(text, final = True)
return strip_tool_markup(text, final = True, enabled_tool_names = enabled_tool_names)
def _status_for_tool(tool_name: str, arguments: dict) -> str:
@ -81,25 +301,76 @@ def _status_for_tool(tool_name: str, arguments: dict) -> str:
return status_for_tool(tool_name, arguments)
def _looks_like_enabled_bare_json(text: str, enabled_tool_names: Optional[set]) -> bool:
"""True when ``text`` opens with an ENABLED markerless bare-JSON call; an ordinary JSON answer returns False."""
probe = strip_llama3_leading_sentinels(text.lstrip())
if not (probe.startswith("{") and ('"name"' in probe or '"function"' in probe)):
return False
return strip_leading_bare_json_call(probe, enabled_tool_names) != probe
_FUNCTION_SIGNAL_RE = re.compile(r"<function=([\w-]+)>")
_TOOL_CALL_NAME_RE = re.compile(r'"name"\s*:\s*"([\w-]+)"')
# Mistral name/v11 and rehearsal forms, aligned with the parser so the provisional
# render-html card fires for bracket-tag serializations too.
_MISTRAL_RENDER_NAME_RE = re.compile(
r"\[TOOL_CALLS\]\s*([\w-]+)(?:\[CALL_ID\][\w-]+)?(?:\[ARGS\])?\s*(?=\{)"
)
_REHEARSAL_RENDER_NAME_RE = re.compile(r"(?<!\[CALL_ID\])\b([\w-]+)\[ARGS\]\s*(?=\{)")
def _detect_render_html_tool_start(content: str) -> bool:
"""Return True when the first drained tool call is clearly render_html."""
function_match = _FUNCTION_SIGNAL_RE.search(content)
tool_call_index = content.find("<tool_call>")
if not function_match and tool_call_index < 0:
"""Return True when the FIRST tool call in ``content`` is clearly render_html.
Covers every serialization the loop executes (XML ``<function=>`` / ``<tool_call>``,
Mistral ``[TOOL_CALLS]``, rehearsal ``NAME[ARGS]``); the earliest marker wins so a
render_html marker inside another call's argument is treated as data. Markers inside
a ``<think>`` / ``[THINK]`` block are dropped since the parser skips them."""
think_spans = _think_spans_outside_tool_markup(content)
_think_starts = [s for s, _e in think_spans]
def _in_think(pos: int) -> bool:
if not think_spans:
return False
i = bisect.bisect_right(_think_starts, pos) - 1
return i >= 0 and think_spans[i][0] <= pos < think_spans[i][1]
def _first_outside(start: int, finder) -> int:
# First occurrence at/after ``start`` that is not inside a think span.
pos = finder(start)
while pos >= 0 and _in_think(pos):
pos = finder(pos + 1)
return pos
candidates: list[tuple[int, str]] = []
for fm in _FUNCTION_SIGNAL_RE.finditer(content):
if not _in_think(fm.start()):
candidates.append((fm.start(), fm.group(1)))
break
tc = _first_outside(0, lambda i: content.find("<tool_call>", i))
if tc >= 0:
nm = _TOOL_CALL_NAME_RE.search(content[tc:])
candidates.append((tc, nm.group(1) if nm else ""))
mt = _first_outside(0, lambda i: content.find("[TOOL_CALLS]", i))
if mt >= 0:
mm = _MISTRAL_RENDER_NAME_RE.match(content, mt)
if mm:
candidates.append((mt, mm.group(1)))
else:
# Array shape: a bare ``"name"`` search can latch onto an argument key, so resolve the
# first call through the parser (it reads top-level names).
arr_calls = parse_tool_calls_from_text(content[mt:])
if arr_calls:
candidates.append((mt, (arr_calls[0].get("function") or {}).get("name") or ""))
for rm in _REHEARSAL_RENDER_NAME_RE.finditer(content):
if not _in_think(rm.start(1)):
candidates.append((rm.start(1), rm.group(1)))
break
if not candidates:
return False
if function_match and (tool_call_index < 0 or function_match.start() < tool_call_index):
return function_match.group(1) == "render_html"
if tool_call_index >= 0:
name_match = _TOOL_CALL_NAME_RE.search(content[tool_call_index:])
return bool(name_match and name_match.group(1) == "render_html")
return False
_pos, name = min(candidates, key = lambda c: c[0])
return name == "render_html"
def _coerce_arguments_with_provenance(
@ -149,6 +420,7 @@ def run_safetensors_tool_loop(
execute_tool: Callable[..., str],
cancel_event: Optional[threading.Event] = None,
auto_heal_tool_calls: bool = True,
nudge_tool_calls: Optional[bool] = None,
max_tool_iterations: int = 25,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
@ -188,8 +460,17 @@ def run_safetensors_tool_loop(
for _ev in _auto["events"]:
yield _ev
conversation.extend(_auto["messages"])
# Autoinject ran a KB search outside the controller, so it counts as an
# executed tool for the plan-without-action gate.
rag_autoinjected = bool(_auto)
unrestricted_tools = not tools
# Gate telling a genuine NAME[ARGS] rehearsal from inactive-name prose; built from the
# ORIGINAL tools list so a spent one-shot still reads as a tool name. None = unrestricted.
_enabled_names_gate = None if unrestricted_tools else set(_active_tool_names(tools))
# Detection must see the same names as the strip gate (ORIGINAL list, incl. a spent
# one-shot), else its repeat is stripped but never drained and the turn ends blank.
_detect_tools = [] if unrestricted_tools else list(tools or [])
tool_controller = ToolLoopController(
tools = None if unrestricted_tools else tools,
auto_heal_tool_calls = auto_heal_tool_calls,
@ -198,6 +479,14 @@ def run_safetensors_tool_loop(
kb_search_count = 0
final_attempt_done = False
next_call_id = 0
reprompt_count = 0
# A denied tool confirmation must not be answered with a plan-without-action
# re-prompt (which would raise the confirmation gate again).
tool_denied = False
# Real tool-call turns completed. Only turns that actually executed a tool count
# against ``max_tool_iterations``; a duplicate/disabled no-op correction turn (and a
# plan-without-action re-prompt) must not consume budget, matching the GGUF loop.
_executed_tool_iters = 0
def _tool_succeeded(tool_name: str) -> bool:
key_prefix = f"{tool_name}:"
@ -215,9 +504,13 @@ def run_safetensors_tool_loop(
_state_streaming = 1
_state_draining = 2
for iteration in range(max_tool_iterations + 1):
# Reserve re-prompt slots so they don't eat the caller's tool budget.
_extra_iters = MAX_ACT_REPROMPTS if max_tool_iterations > 0 else 0
for iteration in range(max_tool_iterations + _extra_iters + 1):
if cancel_event is not None and cancel_event.is_set():
return
# Whether this turn ran a tool; a no-op-only turn stays False and doesn't consume budget.
_turn_executed_real_tool = False
if final_attempt_done:
active_tools: list[dict] = []
@ -229,6 +522,8 @@ def run_safetensors_tool_loop(
tool_protocol_active = not final_attempt_done and (unrestricted_tools or bool(active_tools))
tool_xml_signals = TOOL_XML_SIGNALS if tool_protocol_active else ()
# Gate the markerless bare-JSON form on enabled names so an ordinary JSON answer isn't misread as a call.
_enabled_tool_names = None if unrestricted_tools else set(_active_tool_names(active_tools))
detect_state = _state_buffering
content_buffer = ""
@ -304,17 +599,18 @@ def run_safetensors_tool_loop(
if detect_state == _state_streaming:
candidate = cumulative_display + delta
signal_pos = -1
for sig in tool_xml_signals:
p = candidate.find(sig)
if p >= 0 and (signal_pos < 0 or p < signal_pos):
signal_pos = p
# Earliest genuine boundary: bare [ARGS] in prose is skipped; a real NAME[ARGS] is
# pulled back to NAME so the name is not flushed.
signal_pos = _earliest_tool_signal(
candidate, tool_xml_signals, _detect_tools, unrestricted = unrestricted_tools
)
if signal_pos >= 0:
before_tool = candidate[:signal_pos]
cleaned_before = strip_tool_markup_streaming(
before_tool,
auto_heal_tool_calls = auto_heal_tool_calls,
tool_protocol_active = tool_protocol_active,
enabled_tool_names = _enabled_names_gate,
)
if len(cleaned_before) > len(last_emitted):
last_emitted = cleaned_before
@ -345,10 +641,20 @@ def run_safetensors_tool_loop(
cumulative_display,
auto_heal_tool_calls = auto_heal_tool_calls,
tool_protocol_active = tool_protocol_active,
enabled_tool_names = _enabled_names_gate,
)
if len(cleaned) > len(last_emitted):
last_emitted = cleaned
yield {"type": "content", "text": cleaned}
# Hold a trailing bare active-tool-name (split rehearsal) until its [ARGS] arrives;
# released by later prose or the end-of-stream flush.
if tool_protocol_active:
_hold = _held_rehearsal_tail_len(
cleaned, _detect_tools, unrestricted = unrestricted_tools
)
emit = cleaned[: len(cleaned) - _hold] if _hold else cleaned
else:
emit = cleaned
if len(emit) > len(last_emitted):
last_emitted = emit
yield {"type": "content", "text": emit}
continue
# BUFFERING: hold until we know it is not a tool call.
@ -366,6 +672,92 @@ def run_safetensors_tool_loop(
if sig.startswith(stripped):
is_prefix = True
break
# Bracket-tag forms arrive mid-buffer, so substring-check too (mirrors GGUF); [ARGS]
# counts only with an active NAME so prose is not drained into a no-op.
if sig == "[ARGS]":
if (
_earliest_tool_signal(
stripped,
("[ARGS]",),
_detect_tools,
unrestricted = unrestricted_tools,
)
>= 0
):
is_match = True
break
elif sig.startswith("[") and sig in stripped:
is_match = True
break
# Split rehearsal: hold the bare name until its [ARGS] arrives and matches above.
is_rehearsal_prefix = False
if (
not is_match
and not is_prefix
and tool_protocol_active
and _is_rehearsal_prefix(stripped, _detect_tools, unrestricted = unrestricted_tools)
):
is_prefix = True
is_rehearsal_prefix = True
# Llama-3.2 ``custom_tools`` emits a bare ``{"name":..,"parameters":..}`` with no XML
# signal. Hold a leading ``{`` (after any sentinel) until it closes: drain if it parses
# as a call, else stream as content. Non-call text is always recovered downstream.
bare_probe = strip_llama3_leading_sentinels(stripped)
if (
not is_match
and not is_prefix
and tool_protocol_active
and bare_probe.startswith("{")
):
if _balanced_brace_end(bare_probe, 0) is None:
if len(stripped) < _MAX_BARE_JSON_BUFFER:
continue # object still open -- keep buffering
elif _looks_like_enabled_bare_json(bare_probe, _enabled_tool_names):
# Oversized still-open ENABLED-tool call: stop holding (memory bound) but
# DRAIN instead of leaking the raw prefix; a giant ordinary JSON answer still streams.
detect_state = _state_draining
continue
elif parse_tool_calls_from_text(
content_buffer,
id_offset = next_call_id,
allow_incomplete = auto_heal_tool_calls,
enabled_tool_names = _enabled_tool_names,
):
# Closed object that parses as a bare-JSON call -- drain silently.
detect_state = _state_draining
continue
# Closed non-call object (or oversized non-call) -- stream as text.
# Gemma wrapper-less ``call:NAME{...}`` has no tool_xml_signals entry:
# buffer it here or it streams raw until the end-of-turn safety net.
# ``(?<!\w)`` keeps "recall:" out; the prefix regex is whitespace-tolerant.
if (
not is_match
and not is_prefix
and tool_protocol_active
and (
"call:".startswith(stripped)
or _GEMMA_BARE_TC_PREFIX_RE.match(stripped) is not None
or _GEMMA_BARE_TC_RE.match(stripped) is not None
)
):
if _GEMMA_BARE_TC_RE.match(stripped):
detect_state = _state_draining
continue
# A ``call:`` / ``call:partial_name`` prefix with no ``{`` yet: keep
# buffering the variable-length name instead of leaking ``call:longname``.
# Names can exceed 32 chars (OpenAI 64, MCP longer), so a fixed cap would
# flush real calls raw. The prefix regex self-terminates on ordinary prose
# and the ``{`` drains above; bound generously like the bare-JSON path.
if _GEMMA_BARE_TC_PREFIX_RE.match(stripped) is not None:
if len(stripped) < _MAX_BARE_JSON_BUFFER:
continue
detect_state = _state_draining
continue
if len(stripped) < _MAX_BUFFER_CHARS:
continue # bare "call:" prefix still forming
if is_match:
# Tool signal -- flush any visible prefix before DRAINING
@ -375,6 +767,7 @@ def run_safetensors_tool_loop(
cumulative_display,
auto_heal_tool_calls = auto_heal_tool_calls,
tool_protocol_active = tool_protocol_active,
enabled_tool_names = _enabled_names_gate,
)
if len(cleaned) > len(last_emitted):
last_emitted = cleaned
@ -398,7 +791,8 @@ def run_safetensors_tool_loop(
"arguments": {},
"provenance": _tool_event_provenance(provisional = True),
}
elif is_prefix and len(stripped) < _MAX_BUFFER_CHARS:
elif is_prefix and (is_rehearsal_prefix or len(stripped) < _MAX_BUFFER_CHARS):
# A rehearsal prefix is self-bounded; the buffer cap must not cut long MCP names short.
continue
else:
detect_state = _state_streaming
@ -407,57 +801,121 @@ def run_safetensors_tool_loop(
cumulative_display,
auto_heal_tool_calls = auto_heal_tool_calls,
tool_protocol_active = tool_protocol_active,
enabled_tool_names = _enabled_names_gate,
)
if len(cleaned) > len(last_emitted):
last_emitted = cleaned
yield {"type": "content", "text": cleaned}
# Same trailing-name hold as STREAMING for this first flush out of BUFFERING.
if tool_protocol_active:
_hold = _held_rehearsal_tail_len(
cleaned, _detect_tools, unrestricted = unrestricted_tools
)
emit = cleaned[: len(cleaned) - _hold] if _hold else cleaned
else:
emit = cleaned
if len(emit) > len(last_emitted):
last_emitted = emit
yield {"type": "content", "text": emit}
# Stream finished -- resolve what we collected.
if cancel_event is not None and cancel_event.is_set():
return
if detect_state == _state_buffering:
# Buffer never resolved -- tool XML or plain content?
# Buffer never resolved: [ARGS] is name-gated so a prose answer with a literal
# ``foo[ARGS]{...}`` is not parsed.
stripped = content_buffer.lstrip()
_bare_eos = strip_llama3_leading_sentinels(stripped)
if (
stripped
and tool_protocol_active
and any(sig in stripped for sig in tool_xml_signals)
and _has_genuine_tool_signal(
stripped,
tool_xml_signals,
_detect_tools,
unrestricted = unrestricted_tools,
)
):
detect_state = _state_draining
elif tool_protocol_active and _looks_like_enabled_bare_json(
_bare_eos, _enabled_tool_names
):
# A held bare-JSON ENABLED-tool fragment has no XML signal; DRAIN it (an ordinary
# JSON answer falls through to the else and streams as content, GGUF parity).
detect_state = _state_draining
else:
# Drain and fall through to STREAMING so the intent re-prompt + safety-net parser
# still fire on short emissions like "Let me search." that never exit BUFFERING.
if content_buffer:
cumulative_display += content_buffer
yield {
"type": "content",
"text": _strip_tool_markup_final(
cumulative_display,
auto_heal_tool_calls = auto_heal_tool_calls,
tool_protocol_active = False,
),
}
yield {"type": "status", "text": ""}
return
cleaned = strip_tool_markup(
cumulative_display, final = True, enabled_tool_names = _enabled_tool_names
)
if len(cleaned) > len(last_emitted):
last_emitted = cleaned
yield {"type": "content", "text": cleaned}
detect_state = _state_streaming
if detect_state == _state_streaming:
# No tool detected mid-stream -- check for late tool XML.
safety_tc = None
saw_tool_signal = tool_protocol_active and any(
sig in content_accum for sig in tool_xml_signals
# Run the parser even with no XML signal (the Llama-3.2 bare-JSON form carries none); it's
# strict so plain answers stay untouched. Mirrors GGUF.
safety_tc = parse_tool_calls_from_text(
content_accum,
id_offset = next_call_id,
allow_incomplete = auto_heal_tool_calls,
enabled_tool_names = _enabled_tool_names,
)
if saw_tool_signal:
safety_tc = parse_tool_calls_from_text(
content_accum,
id_offset = next_call_id,
allow_incomplete = auto_heal_tool_calls,
)
if not safety_tc:
# Final answer: if a literal tool marker in prose was stripped
# during streaming but did not parse as a real call, restore the
# raw cumulative text for core callers. Route-level cleanup can
# still apply the Auto-Heal display policy.
if saw_tool_signal and content_accum:
# Re-prompt once on plan-without-action, before any tool runs
# (GGUF loop parity). The retry is gated on nudge_tool_calls so
# Studio callers (which send True) always nudge, while API callers
# who omit the flag keep today's no-reprompt behavior (opt-in).
stripped_answer = content_accum.strip()
if (
auto_heal_tool_calls
and nudge_tool_calls
and active_tools
and reprompt_count < MAX_ACT_REPROMPTS
and not rag_autoinjected
and not tool_denied
and not any(record.executed for record in tool_controller.history)
and is_short_intent_without_action(stripped_answer)
):
reprompt_count += 1
logger.info(
"Safetensors re-prompt %d/%d: model responded without "
"calling tools (%d chars)",
reprompt_count,
MAX_ACT_REPROMPTS,
len(stripped_answer),
)
conversation.append({"role": "assistant", "content": stripped_answer})
tool_hint = " or ".join(_active_tool_names(active_tools)) or "an available tool"
conversation.append(
{
"role": "user",
"content": reprompt_to_act_message(tool_hint),
}
)
# Empty status clears the badge and resets the route's
# per-turn text cursor before the re-prompted turn streams.
yield {"type": "status", "text": ""}
continue
# Final answer. If a literal tool marker in prose was buffered but
# never parsed as a call, restore the raw text so the prose surfaces
# in full; route-level cleanup still applies the Auto-Heal policy.
if content_accum and any(sig in content_accum for sig in tool_xml_signals):
yield {"type": "content", "text": content_accum}
else:
# Turn ended as a plain answer (no [ARGS] followed): the held rehearsal tail is real
# prose, release it.
final_clean = strip_tool_markup_streaming(
cumulative_display,
auto_heal_tool_calls = auto_heal_tool_calls,
tool_protocol_active = tool_protocol_active,
enabled_tool_names = _enabled_names_gate,
)
if len(final_clean) > len(last_emitted):
yield {"type": "content", "text": final_clean}
yield {"type": "status", "text": ""}
return
tool_calls = safety_tc
@ -465,31 +923,41 @@ def run_safetensors_tool_loop(
content_accum,
auto_heal_tool_calls = auto_heal_tool_calls,
tool_protocol_active = True,
enabled_tool_names = _enabled_names_gate,
)
logger.info(
"Safetensors safety net: parsed %d tool call(s) from streamed content",
len(tool_calls),
)
else:
# DRAINING: parse tool calls out of full content.
# DRAINING: parse tool calls out of full content. Gate the bare rehearsal on the
# ORIGINAL tool list (``_enabled_names_gate``), the same names detection/strip used to
# drain here: a spent one-shot (render_html) is off the active list but its re-emitted
# ``render_html[ARGS]{..}`` must still parse so it routes to the repeat no-op instead of
# being dropped into a blank continuation.
tool_calls = parse_tool_calls_from_text(
content_accum,
id_offset = next_call_id,
allow_incomplete = auto_heal_tool_calls,
enabled_tool_names = _enabled_names_gate,
)
if not tool_calls:
# Parser found nothing. Auto-Heal-enabled display cleanup
# strips unparseable tool XML; disabled Auto-Heal preserves
# the raw text so literal/malformed markup stays visible.
if content_accum:
yield {
"type": "content",
"text": _strip_tool_markup_final(
content_accum,
auto_heal_tool_calls = auto_heal_tool_calls,
tool_protocol_active = False,
),
}
_drain_text = _strip_tool_markup_final(
content_accum,
auto_heal_tool_calls = auto_heal_tool_calls,
tool_protocol_active = False,
enabled_tool_names = _enabled_tool_names,
)
# Drained bare-JSON call that didn't parse: with Auto-Heal on, drop the fragment
# (plain JSON answers are left untouched); off keeps it visible per the strict contract.
if tool_protocol_active and auto_heal_tool_calls:
_drain_text = strip_leading_bare_json_call(_drain_text, _enabled_tool_names)
if _drain_text:
yield {"type": "content", "text": _drain_text}
if provisional_render_html_started and not provisional_resolved:
provisional_resolved = True
yield {
@ -505,10 +973,14 @@ def run_safetensors_tool_loop(
content_accum,
auto_heal_tool_calls = auto_heal_tool_calls,
tool_protocol_active = True,
enabled_tool_names = _enabled_names_gate,
)
if tool_calls:
next_call_id += len(tool_calls)
# Strip a leading bare-JSON call from the kept content so it isn't replayed as text or
# next-turn history (``_strip_tool_markup_final`` only knows XML). No-op for plain JSON answers.
content_text = strip_leading_bare_json_call(content_text, _enabled_tool_names)
if final_attempt_done:
# Final-answer turn re-called a tool -- stop the loop.
@ -517,6 +989,27 @@ def run_safetensors_tool_loop(
yield {"type": "status", "text": ""}
return
# Collapse exact-duplicate calls and cap the count (runaway-turn guard).
if tool_calls:
seen_keys: set = set()
deduped: list = []
for _tc in tool_calls:
_fn = _tc.get("function", {}) or {}
_key = (_fn.get("name", ""), str(_fn.get("arguments", "")))
if _key in seen_keys:
continue
seen_keys.add(_key)
deduped.append(_tc)
if len(deduped) >= _MAX_TOOL_CALLS_PER_TURN:
break
if len(deduped) != len(tool_calls):
logger.info(
"Safetensors: collapsed %d repeated tool call(s) in one turn to %d",
len(tool_calls),
len(deduped),
)
tool_calls = deduped
assistant_msg: dict = {"role": "assistant", "content": content_text}
assistant_appended = False
@ -593,6 +1086,7 @@ def run_safetensors_tool_loop(
"result": TOOL_REJECTED_MESSAGE,
"provenance": decision.provenance,
}
tool_denied = True
denied_message = {
"role": "tool",
"name": decision.tool_name,
@ -634,6 +1128,8 @@ def run_safetensors_tool_loop(
completion = tool_controller.record_result(decision, result)
if provisional_match:
provisional_resolved = True
# A tool ran this turn, so it counts against the caller's budget.
_turn_executed_real_tool = True
yield completion.tool_end_event()
conversation.append(completion.tool_message())
@ -646,7 +1142,11 @@ def run_safetensors_tool_loop(
if not unrestricted_tools and not tool_controller.active_tools():
final_attempt_done = True
continue
if iteration + 1 >= max_tool_iterations and not final_attempt_done:
# Count only turns that executed a tool against the cap; a no-op correction turn doesn't
# consume budget so the model gets its nudge and another tool-enabled turn (GGUF parity).
if _turn_executed_real_tool:
_executed_tool_iters += 1
if _executed_tool_iters >= max_tool_iterations and not final_attempt_done:
# Budget exhausted; nudge a final plain answer.
final_attempt_done = True
conversation.append({"role": "user", "content": BUDGET_EXHAUSTED_NUDGE})

File diff suppressed because it is too large Load diff

View file

@ -13,6 +13,7 @@ mp.Queue, and exits on shutdown or unload. Pattern follows core/training/worker.
from __future__ import annotations
import base64
import json
from loggers import get_logger
import os
import queue as _queue
@ -26,6 +27,9 @@ from typing import Any
logger = get_logger(__name__)
from utils.hardware import apply_gpu_ids
_SHARE_OBJECT_MAX_BYTES = 1 << 20
_SHARE_OBJECT_ERROR_SIZE = -1
# studio/backend root, prepended to sys.path so the spawned subprocess can
# import the utils/core packages.
_BACKEND_PATH = str(Path(__file__).resolve().parent.parent.parent)
@ -75,6 +79,17 @@ def _send_response(resp_queue: Any, response: dict) -> None:
logger.error("Failed to send response: %s", exc)
def _encode_share_object(obj: Any) -> bytes:
data = json.dumps(obj, separators = (",", ":"), ensure_ascii = False).encode("utf-8")
if len(data) > _SHARE_OBJECT_MAX_BYTES:
raise ValueError("Distributed object share payload is too large")
return data
def _decode_share_object(data: Any) -> Any:
return json.loads(bytes(data.tolist()).decode("utf-8"))
def _clean_token(value: str | None) -> str | None:
"""Normalize an HF token: blank or whitespace-only becomes None."""
return value if value and value.strip() else None
@ -329,14 +344,18 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
xet_disabled = os.environ.get("HF_HUB_DISABLE_XET") == "1",
)
try:
success = backend.load_model(
config = mc,
max_seq_length = config.get("max_seq_length", 2048),
load_in_4bit = load_in_4bit,
hf_token = hf_token,
trust_remote_code = trust_remote_code,
gpu_ids = config.get("resolved_gpu_ids"),
)
load_kwargs = {
"config": mc,
"max_seq_length": config.get("max_seq_length", 2048),
"load_in_4bit": load_in_4bit,
"hf_token": hf_token,
"trust_remote_code": trust_remote_code,
"gpu_ids": config.get("resolved_gpu_ids"),
}
if getattr(backend, "device", None) == "mlx":
load_kwargs["parallel_mode"] = config.get("mlx_parallel_mode")
load_kwargs["distributed_group"] = config.get("_mlx_distributed_group")
success = backend.load_model(**load_kwargs)
finally:
heartbeat_stop.set()
@ -406,6 +425,32 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
)
def _drain_skip_generate(cmd: dict, resp_queue: Any, drain_event) -> bool:
"""Skip a generate queued behind a cancelled one during an unload.
The parent sets ``drain_event`` for the whole unload. Because the parent's
per-token ``cancel_event`` is cleared at the start of every generate, a cancel
set while this generate was still queued would otherwise be lost when it is
dequeued. If the drain is in effect, emit an immediate (empty) ``gen_done`` so
the parent's stream/mailbox drains fast and the switch stays fast, and report
the generate was skipped so the caller does not clear the cancel or run it.
"""
if drain_event is None or not drain_event.is_set():
return False
request_id = cmd.get("request_id", "")
logger.info("Skipping generate for request %s: unload draining", request_id)
_send_response(
resp_queue,
{
"type": "gen_done",
"request_id": request_id,
"cancelled": True,
"stats": None,
},
)
return True
def _handle_generate(backend, cmd: dict, resp_queue: Any, cancel_event) -> None:
"""Handle a generate command: stream tokens back via resp_queue.
@ -431,6 +476,7 @@ def _handle_generate(backend, cmd: dict, resp_queue: Any, cancel_event) -> None:
"min_p": cmd.get("min_p", 0.0),
"max_new_tokens": cmd.get("max_new_tokens", 256),
"repetition_penalty": cmd.get("repetition_penalty", 1.0),
"presence_penalty": cmd.get("presence_penalty", 0.0),
"cancel_event": cancel_event,
}
@ -494,6 +540,67 @@ def _handle_generate(backend, cmd: dict, resp_queue: Any, cancel_event) -> None:
)
def _handle_share_object(backend, cmd: dict, resp_queue: Any) -> None:
"""Share a small Python object across MLX distributed ranks."""
request_id = cmd.get("request_id", "")
group = getattr(backend, "_distributed_group", None)
rank = int(getattr(backend, "_distributed_rank", 0) or 0)
world_size = int(getattr(backend, "_distributed_world_size", 1) or 1)
obj = cmd.get("object")
try:
if group is None or world_size <= 1:
shared = obj
else:
import mlx.core as mx
if rank == 0:
if obj is None:
mx.eval(mx.distributed.all_sum(mx.array(0), group = group))
shared = None
else:
try:
data = mx.array(_encode_share_object(obj), dtype = mx.uint8)
except Exception:
mx.eval(
mx.distributed.all_sum(
mx.array(_SHARE_OBJECT_ERROR_SIZE),
group = group,
)
)
raise
mx.eval(mx.distributed.all_sum(mx.array(data.size), group = group))
mx.eval(mx.distributed.all_sum(data, group = group))
shared = obj
else:
size = int(mx.distributed.all_sum(mx.array(0), group = group).item())
if size == _SHARE_OBJECT_ERROR_SIZE:
raise RuntimeError("Failed to share distributed object")
if size == 0:
shared = None
else:
data = mx.zeros(size, dtype = mx.uint8)
data = mx.distributed.all_sum(data, group = group)
shared = _decode_share_object(data)
_send_response(
resp_queue,
{
"type": "shared",
"request_id": request_id,
"object": shared,
},
)
except Exception as exc:
_send_response(
resp_queue,
{
"type": "share_error",
"request_id": request_id,
"error": str(exc),
"stack": traceback.format_exc(limit = 20),
},
)
def _handle_generate_audio(backend, cmd: dict, resp_queue: Any) -> None:
"""Handle TTS audio generation — returns WAV bytes + sample_rate."""
request_id = cmd.get("request_id", "")
@ -632,7 +739,14 @@ def _handle_unload(backend, cmd: dict, resp_queue: Any) -> None:
)
def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, config: dict) -> None:
def run_inference_process(
*,
cmd_queue: Any,
resp_queue: Any,
cancel_event,
config: dict,
drain_event = None,
) -> None:
"""Subprocess entrypoint. Persistent — runs the command loop until shutdown.
Args:
@ -640,6 +754,10 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf
resp_queue: mp.Queue for sending responses to parent.
cancel_event: mp.Event the parent sets to cancel generation.
config: Initial configuration dict with model info.
drain_event: mp.Event the parent sets for the duration of an unload. Unlike
cancel_event (cleared at the start of every generate), it is never cleared
here, so a generate still queued behind a cancelled one is skipped rather
than run the cancel survives the queue handoff.
"""
os.environ["TOKENIZERS_PARALLELISM"] = "false"
os.environ["PYTHONWARNINGS"] = "ignore" # Suppress warnings at C-level before imports
@ -682,9 +800,29 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf
exc,
)
try:
from core.inference.mlx_inference import MLXInferenceBackend
from core.inference.mlx_inference import MLXInferenceBackend, _init_mlx_distributed
backend = MLXInferenceBackend()
if config.get("mlx_distributed"):
group, rank, size = _init_mlx_distributed()
config["_mlx_distributed_group"] = group
if size <= 1:
# A singleton group (MLX built without distributed support,
# or an invalid launch env/hostfile) would leave nonzero ranks
# looping forever on share_distributed_object. Fail the load
# instead of silently continuing without sharding.
raise RuntimeError(
"MLX distributed launch requested but initialized a singleton "
"group (size 1). Ensure the installed MLX has distributed "
"support and the launch environment/hostfile is valid, or run "
"without distributed."
)
logger.info(
"MLX distributed initialized in worker: rank=%s size=%s mode=%s",
rank,
size,
config.get("mlx_parallel_mode"),
)
_send_response(
resp_queue,
{"type": "status", "message": "Loading model..."},
@ -715,8 +853,19 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf
cmd_type = cmd.get("type", "")
try:
if cmd_type == "generate":
if _drain_skip_generate(cmd, resp_queue, drain_event):
continue
cancel_event.clear()
# Re-check the drain after clearing: the parent sets drain_event
# then cancel_event for an unload, so if that pair landed between
# the check above and this clear, the clear just erased the unload's
# cancel. Skip here so the outgoing model is not run to completion,
# which would stall the switch until the dispatcher idle-timeout.
if _drain_skip_generate(cmd, resp_queue, drain_event):
continue
_handle_generate(backend, cmd, resp_queue, cancel_event)
elif cmd_type == "share_object":
_handle_share_object(backend, cmd, resp_queue)
elif cmd_type == "load":
if backend.active_model_name:
backend.unload_model(backend.active_model_name)
@ -918,9 +1067,21 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf
try:
if cmd_type == "generate":
if _drain_skip_generate(cmd, resp_queue, drain_event):
continue
cancel_event.clear()
# Re-check the drain after clearing: the parent sets drain_event then
# cancel_event for an unload, so if that pair landed between the check
# above and this clear, the clear just erased the unload's cancel. Skip
# here so the outgoing model is not run to completion, which would stall
# the switch until the dispatcher idle-timeout tears the subprocess down.
if _drain_skip_generate(cmd, resp_queue, drain_event):
continue
_handle_generate(backend, cmd, resp_queue, cancel_event)
elif cmd_type == "share_object":
_handle_share_object(backend, cmd, resp_queue)
elif cmd_type == "load":
if backend.active_model_name:
backend.unload_model(backend.active_model_name)

View file

@ -63,6 +63,87 @@ def _install_torchao_stub_once() -> None:
install_torchao_windows_rocm_stub()
class UnsafeEmbeddingModelError(RuntimeError):
"""Raised when the embedding model repo is flagged unsafe. A distinct type so the
llama-server fallback paths re-raise it instead of masking a security block as a
routine ST failure."""
def _ambient_hf_token() -> str | None:
"""The HF token the loader itself would use (HF_TOKEN env or the cached login), so
the scan can reach a gated/private repo instead of failing open. None if unavailable."""
try:
from huggingface_hub import get_token
return get_token()
except Exception:
return None
def _st_module_subdirs(name: str, token: str | None) -> tuple[str, ...]:
"""The module directories a SentenceTransformer load reads weights from, taken from
the repo's ``modules.json`` (each module's non-empty ``path``, e.g. ``0_Transformer``).
ST deserializes ``pytorch_model.bin`` from these dirs, so they are load roots for the
security scan: a flagged pickle directly under one must block. Returns () on any
failure (no modules.json, offline, malformed) so the guard never bricks the embedder.
"""
try:
import json
from utils.paths import is_local_path
if is_local_path(name):
from pathlib import Path
from utils.paths import normalize_path
path = Path(normalize_path(name)).expanduser() / "modules.json"
if not path.is_file():
return ()
data = json.loads(path.read_text())
else:
from huggingface_hub import hf_hub_download
from huggingface_hub.utils import EntryNotFoundError
try:
local = hf_hub_download(name, "modules.json", token = token or None)
except EntryNotFoundError:
return ()
data = json.loads(open(local).read())
subdirs = []
for module in data or ():
sub = str((module or {}).get("path", "")).strip().strip("/")
if sub:
subdirs.append(sub)
return tuple(dict.fromkeys(subdirs))
except Exception:
return ()
def _guard_model_security(name: str) -> None:
"""Refuse to load a repo HF flagged as unsafe: a poisoned pickle deserializes inside
SentenceTransformer regardless of trust_remote_code. Defense in depth behind the
/settings gate (a name can also arrive via env/default); local paths and unreachable
scans fail open inside evaluate_file_security. Never bricks the embedder on a gate error.
"""
try:
from utils.security import evaluate_file_security, security_load_subdirs
token = _ambient_hf_token()
# Union the audio-model load roots with the ST module dirs so a flagged pickle
# directly under a Transformer module dir (0_Transformer/) blocks instead of
# passing as an unreferenced nested shard.
load_subdirs = tuple(
dict.fromkeys((*security_load_subdirs(name, token), *_st_module_subdirs(name, token)))
)
blocked = evaluate_file_security(name, hf_token = token, load_subdirs = load_subdirs).blocked
except Exception:
return
if blocked:
raise UnsafeEmbeddingModelError(
f"Embedding model {name!r} is flagged as unsafe by Hugging Face's security "
"scan; refusing to load. Set a different RAG embedding model."
)
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."""
@ -75,6 +156,7 @@ def _get(model_name: str | None = None):
device = _device()
logger.info("loading embedding model %s on %s", name, device)
_guard_model_security(name)
_model = SentenceTransformer(
name, device = device, model_kwargs = {"torch_dtype": "float16"}
)
@ -159,6 +241,8 @@ class _SentenceTransformersBackend:
):
try:
return _st_encode(texts, model_name = model_name, normalize = normalize)
except UnsafeEmbeddingModelError:
raise # a security block must hard-fail, not fall back to llama-server
except Exception as st_err: # noqa: BLE001 - runtime ST/CUDA encode failure
# ST loaded but this encode blew up; swap the process to the llama-server
# embedder (so later encodes stay in one space) and retry.
@ -222,6 +306,8 @@ def _build_st_backend_or_fallback():
try:
backend.warm(model_name = None)
return backend
except UnsafeEmbeddingModelError:
raise # a security block must hard-fail, not fall back to llama-server
except Exception as st_err: # noqa: BLE001 - any ST/torch import or load failure
fallback = _try_make_llama_backend()
if fallback is None:
@ -290,6 +376,37 @@ def _reset_backend() -> None:
_backend_key = None
def active_backend_is_llama() -> bool:
"""True when this process actually embeds via the llama-server (GGUF) backend.
Reflects the ACTUAL built backend once one exists: an ``auto`` install that
resolves to sentence-transformers but then falls back to llama-server at
runtime (``_build_st_backend_or_fallback`` on a torch/CUDA load failure, or
``_switch_to_llama_fallback`` on an encode failure) loads only inert GGUF, so
callers gating on the ST pickle must see llama here. Before any backend is
built, defers to the resolver (``auto`` -> ``_resolve_auto()``, else the raw
key) exactly as a fresh process would. Never raises: a backend probe must not
block saving a model."""
try:
with _backend_lock:
backend = _backend
if backend is not None:
# A backend exists: report what it ACTUALLY is. A concrete
# sentence-transformers backend must return False even if the
# resolver would now pick llama, so its pickle stays gated. If the
# llama import fails we cannot be llama, so fall to the safe False.
try:
from .embed_llama_server import LlamaServerBackend
except Exception: # noqa: BLE001 - llama plumbing import must never block
return False
return isinstance(backend, LlamaServerBackend)
raw = (config.EMBED_BACKEND or "auto").strip().lower()
key = _resolve_auto() if raw in _AUTO_ALIASES else raw
return key in _LLAMA_ALIASES
except Exception: # noqa: BLE001 - a backend probe must never block saving
return False
def warm(model_name: str | None = None) -> None:
"""Eagerly load the embedder so the first real request isn't slow."""
_get_backend().warm(model_name = model_name)

View file

@ -1,38 +1,137 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
#
# Bracket-tag, rehearsal, and thinking-block-strip logic adapted from forge
# (https://github.com/antoinezambelli/forge), Copyright (c) 2025-2026
# Antoine Zambelli, used under the MIT License.
"""Lightweight tool-call XML parsing and stripping helpers.
"""Lightweight tool-call parsing and stripping helpers.
External inference servers import this module without pulling in the inference
orchestrator, structlog, httpx, or the rest of the studio backend.
orchestrator, structlog, httpx, or the rest of the studio backend. Kept in
lockstep with ``core/inference/tool_call_parser.py`` so those servers
(llama-server wrappers, llama-swap, custom shims) reuse the same logic. Any
change here must also land there.
Handles these serializations (see ``parse_tool_calls_from_text``):
* ``<tool_call>{json}</tool_call>``
* ``<|tool_call>call:name{...}<tool_call|>`` (Gemma)
* ``<function=name><parameter=k>v</parameter></function>``
* ``[TOOL_CALLS]name{json}`` (Mistral / Devstral fallback)
* ``name[ARGS]{json}`` (reasoning-model rehearsal)
"""
# PEP 604 annotations must stay import-safe on Python 3.9 (requires-python >=3.9).
from __future__ import annotations
import bisect
import json
import re
# Pre-compiled patterns for tool XML stripping. The hyphen in the name
# char-class lets dashed MCP tool/parameter names (mcp__srv__list-issues,
# issue-number) parse alongside the built-ins.
# One nesting level in the strip regexes; deeper may leak markup (still parsed).
_BRACKETED_JSON_ONE_LEVEL = r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}"
# Rehearsal ``name[ARGS]{..}`` strips; group 1 = name for tool-list gating. Closed =
# complete body, tail = truncated; ``(?<!\[CALL_ID\])`` keeps the v11 call-id from reading as a name.
_REHEARSAL_CLOSED_STRIP_RE = re.compile(
r"(?<!\[CALL_ID\])\b([\w-]+)\[ARGS\]\s*" + _BRACKETED_JSON_ONE_LEVEL, re.DOTALL
)
_REHEARSAL_TAIL_STRIP_RE = re.compile(r"(?<!\[CALL_ID\])\b([\w-]+)\[ARGS\]\s*(?:\{.*)?$", re.DOTALL)
# Tool-XML strip patterns; hyphen in the name class covers dashed MCP names.
# Closed-pair patterns are named so _PAT_REQUIRED_TOKEN can skip a doomed lazy rescan when
# the close token is absent: an unguarded ``<tag>.*?</tag>`` rescans to EOF from every opener
# (quadratic on a stream of unclosed openers). Also reused by the quote-aware Gemma pre-pass.
_TC_JSON_CLOSED_PAT = re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL)
_TC_GEMMA_CLOSED_PAT = re.compile(r"<\|tool_call>.*?<tool_call\|>", re.DOTALL)
_TC_FUNC_CLOSED_PAT = re.compile(r"<function=[\w-]+>.*?</function>", re.DOTALL)
_TOOL_CLOSED_PATS = [
re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL),
re.compile(r"<\|tool_call>.*?<tool_call\|>", re.DOTALL),
_TC_JSON_CLOSED_PAT,
_TC_GEMMA_CLOSED_PAT,
re.compile(r"<tool_call\|>"),
re.compile(r"<function=[\w-]+>.*?</function>", re.DOTALL),
_TC_FUNC_CLOSED_PAT,
# Mirror the parser regexes: tolerate whitespace and v11 [CALL_ID]/[ARGS] metadata.
re.compile(
r"\[TOOL_CALLS\]\s*[\w-]+(?:\[CALL_ID\][\w-]+)?(?:\[ARGS\])?\s*"
+ _BRACKETED_JSON_ONE_LEVEL,
re.DOTALL,
),
_REHEARSAL_CLOSED_STRIP_RE,
# Drop the bare v11 [/TOOL_CALLS] closer the balanced scan leaves behind.
re.compile(r"\[/TOOL_CALLS\]"),
]
_TOOL_ALL_PATS = _TOOL_CLOSED_PATS + [
# Bare open markers strip a partial call mid-stream; the rehearsal tail needs `{` or EOF
# so prose ``foo[ARGS]`` survives. The XML open-tail forms reach EOF and are reused by
# _tool_call_markup_spans (a think tag in an unclosed call's args stays argument data).
_TOOL_OPEN_XML_TAIL_PATS = [
re.compile(r"<tool_call>.*$", re.DOTALL),
re.compile(r"<\|tool_call>.*$", re.DOTALL),
re.compile(r"<function=[\w-]+>.*$", re.DOTALL),
]
_TOOL_ALL_PATS = (
_TOOL_CLOSED_PATS
+ _TOOL_OPEN_XML_TAIL_PATS
+ [
re.compile(r"\[TOOL_CALLS\].*$", re.DOTALL),
_REHEARSAL_TAIL_STRIP_RE,
]
)
# Rehearsal strips (name in group 1); name-gated via ``enabled_tool_names``, strip-all when None.
_REHEARSAL_STRIP_PATS = frozenset({_REHEARSAL_CLOSED_STRIP_RE, _REHEARSAL_TAIL_STRIP_RE})
# Stripped before the quote-aware Gemma helper so a Gemma opener quoted in argument
# data cannot make the helper truncate the block and its tail.
_TOOL_CLOSED_BLOCK_PATS = [_TC_JSON_CLOSED_PAT, _TC_FUNC_CLOSED_PAT]
# A lazy closed-pair pattern whose close token is absent would rescan to EOF from every
# opener; skip that doomed (quadratic) pass. Shared by both strip helpers.
_PAT_REQUIRED_TOKEN = {
_TC_JSON_CLOSED_PAT: "</tool_call>",
_TC_GEMMA_CLOSED_PAT: "<tool_call|>",
_TC_FUNC_CLOSED_PAT: "</function>",
}
def strip_tool_patterns(text: str, patterns) -> str:
"""Apply ``patterns`` in order, skipping closed-pair passes with no close token."""
for pat in patterns:
token = _PAT_REQUIRED_TOKEN.get(pat)
if token is not None and token not in text:
continue
text = pat.sub("", text)
return text
def apply_tool_strip_patterns(
text: str,
patterns,
enabled_tool_names = None,
) -> str:
"""Apply strip ``patterns`` to ``text``. A bare rehearsal ``name[ARGS]{..}`` pattern
strips only when ``name`` is an enabled tool (or when ``enabled_tool_names`` is
``None``); every other pattern is removed unconditionally. A closed-pair pattern whose
close token is absent is skipped so an unclosed-marker stream stays linear."""
for pat in patterns:
token = _PAT_REQUIRED_TOKEN.get(pat)
if token is not None and token not in text:
continue
if enabled_tool_names is not None and pat in _REHEARSAL_STRIP_PATS:
text = pat.sub(lambda m: "" if m.group(1) in enabled_tool_names else m.group(0), text)
else:
text = pat.sub("", text)
return text
# 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_GEMMA_START_RE = re.compile(r"<\|tool_call>\s*call\s*:\s*([\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*")
# Horizontal-whitespace trailing class keeps the wrapping newline; _trim_param_value trims it.
_TC_PARAM_START_RE = re.compile(r"<parameter=([\w-]+)>[^\S\n]*")
_TC_PARAM_CLOSE_RE = re.compile(r"\s*</parameter>\s*$")
_GEMMA_QUOTE = '<|"|>'
_PARAM_CLOSE_TAG = "</parameter>"
@ -43,7 +142,62 @@ _FUNC_CLOSE_TAG = "</function>"
# 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*:")
_GEMMA_NEXT_KEY_RE = re.compile(r"\s*[A-Za-z_][\w.\-]*\s*:")
# A candidate starting inside a think block is a rehearsal (block kept so literal tags in
# real args survive); ``$`` accepts an unclosed block mid-stream.
_THINK_TAG_RE = re.compile(r"<think>.*?(?:</think>|$)|\[THINK\].*?(?:\[/THINK\]|$)", re.DOTALL)
# Bare open/close markers for prefilled-reasoning turns (template opens <think> in the prompt).
_THINK_OPEN_RE = re.compile(r"<think>|\[THINK\]")
_THINK_CLOSE_RE = re.compile(r"</think>|\[/THINK\]")
# Mistral canonical array: [TOOL_CALLS] + JSON list of {"name","arguments"} objects.
_MISTRAL_ARRAY_RE = re.compile(r"\[TOOL_CALLS\]\s*(?=\[)")
# Mistral name form + v11 [ARGS]/[CALL_ID] shapes; [CALL_ID] is metadata, not the name,
# and hyphens keep dashed MCP names whole.
_MISTRAL_BRACKET_RE = re.compile(
r"\[TOOL_CALLS\]\s*([\w-]+)(?:\[CALL_ID\][\w-]+)?(?:\[ARGS\])?\s*(?=\{)"
)
# Rehearsal ``name[ARGS]{json}`` (no [TOOL_CALLS]); the lookbehind keeps the v11 call-id
# from being taken as the function name.
_REHEARSAL_RE = re.compile(r"(?<!\[CALL_ID\])\b([\w-]+)\[ARGS\]\s*(?=\{)")
# Above this size skip the balanced scan; the linear regex catch-all bounds pathological output.
_MAX_BRACKET_SCAN_CHARS = 1_000_000
def _balanced_json_span(text: str, start: int) -> int | None:
"""Return the end index of a balanced JSON object opening at ``start``,
or ``None`` if the braces don't balance. Honors escapes and strings.
"""
if start >= len(text) or text[start] != "{":
return None
depth = 0
in_string = False
escape = False
for j in range(start, len(text)):
ch = text[j]
if escape:
escape = False
continue
if ch == "\\":
escape = True
continue
if in_string:
if ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return j
return None
def _balanced_brace_end(
@ -109,6 +263,94 @@ def _balanced_bracket_end(src: str, start: int) -> int:
return -1
def _decode_array_items(text: str, body_start: int, body_end: int):
"""Return ``(objs, ends)`` for each top-level element of the JSON array between
``body_start`` (at or before its ``[``) and ``body_end`` (exclusive): the decoded
object and its absolute exclusive end offset.
Decoding element-by-element with ``raw_decode`` tolerates the comma-less object
separators the repo's own Mistral/Ollama multi-call templates emit
(``[{...}{...}]``; see ollama_template_mappers.py). A single ``json.loads`` of the
whole body rejects that form and would drop every call. The ends also tile the
region across the calls' spans so a with_spans consumer strips each exactly once."""
decoder = json.JSONDecoder()
objs: list = []
ends: list[int] = []
i = text.find("[", body_start)
if i < 0:
return objs, ends
i += 1
while i < body_end:
while i < body_end and text[i] in " \t\r\n,":
i += 1
if i >= body_end or text[i] == "]":
break
try:
obj, rel = decoder.raw_decode(text[i:body_end])
except (json.JSONDecodeError, ValueError):
break
i += rel
objs.append(obj)
ends.append(i)
return objs, ends
def _iter_bracket_spans(
text: str,
start: int = 0,
enabled_tool_names = None,
):
"""Yield ``(span_start, span_end, kind, match)`` for each balanced bracket-tag
call from ``start`` on, in document order; ``span_end`` exclusive. ``kind`` is
``"array"`` ([TOOL_CALLS] [..]), ``"name"`` ([TOOL_CALLS]name{..}, incl. v11
[CALL_ID]/[ARGS]) or ``"rehearsal"`` (name[ARGS]{..}).
``enabled_tool_names`` (set, or None = unrestricted) gates only the ambiguous
bare rehearsal form: name[ARGS]{..} is a call ONLY when ``name`` is enabled, so a
prose ``foo[ARGS]{..}`` (foo disabled) is neither parsed nor stripped. Explicit
[TOOL_CALLS] markers stay unconditional, keeping parse/strip/detection symmetric.
Balance-only (no JSON validation) so strip and parse share one scan. The cursor
jumps past each consumed span, so a marker inside consumed JSON is never
re-matched and each regex re-searches only once its match falls behind: linear."""
n = len(text)
specs = (
("array", _MISTRAL_ARRAY_RE),
("name", _MISTRAL_BRACKET_RE),
("rehearsal", _REHEARSAL_RE),
)
nexts = {kind: rx.search(text, start) for kind, rx in specs}
cursor = start
while cursor < n:
for kind, rx in specs:
m = nexts[kind]
if m is not None and m.start() < cursor:
nexts[kind] = rx.search(text, cursor)
live = [(kind, m) for kind, m in nexts.items() if m is not None]
if not live:
return
kind, m = min(live, key = lambda km: km[1].start())
if kind == "array":
end = _balanced_bracket_end(text, m.end())
end = None if end < 0 else end
else:
end = _balanced_json_span(text, m.end())
if end is None:
# Truncated body: skip and keep scanning; the caller's catch-all strips the tail.
cursor = m.end()
continue
if (
kind == "rehearsal"
and enabled_tool_names is not None
and m.group(1) not in enabled_tool_names
):
# Inactive-name rehearsal is prose: advance past its body without yielding.
cursor = end + 1
continue
yield (m.start(), end + 1, kind, m)
cursor = end + 1
def _split_top_level_commas(src: str) -> list:
"""Split on commas that are not inside a nested ``[]``/``{}`` or a string."""
parts: list[str] = []
@ -223,7 +465,7 @@ def _quote_gemma_object_keys(src: str) -> str:
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 "_-"):
while i < len(src) and (src[i].isalnum() or src[i] in "_-."):
i += 1
key_name = src[key_name_start:i]
colon_pos = i
@ -267,7 +509,8 @@ def _quote_gemma_object_keys(src: str) -> str:
json.loads(raw.strip())
parts.append(raw)
except (json.JSONDecodeError, ValueError):
parts.append(json.dumps(raw.strip()) if raw.strip() else raw)
# Quote bare value; empty ({k:}) becomes "" so json.loads sees {"k":""} not invalid {"k":}.
parts.append(json.dumps(raw.strip()))
else:
parts.append(src[key_start:i])
return "".join(parts)
@ -291,9 +534,99 @@ def _inside_open_parameter(content: str, pos: int) -> bool:
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)
# The parameter's OWN close tag decides: if it closes after ``pos`` the position is
# argument data (even across literal function closes); an unclosed one falls back to func close.
own_close = content.find(_PARAM_CLOSE_TAG, last_param_start)
if own_close >= 0:
return own_close > pos
func_close = content.find(_FUNC_CLOSE_TAG, last_param_start)
return func_close < 0 or pos < func_close
def _func_close_index(content: str, body_start: int, body: str) -> int:
"""Index in ``body`` of the first ``</function>`` that is not argument
data (not inside an open parameter value); -1 when every close is data.
Taking the LAST close swallowed prose between the real close and a
literal ``</function>`` mentioned later in the answer."""
idx = body.find(_FUNC_CLOSE_TAG)
while idx >= 0:
if not _inside_open_parameter(content, body_start + idx):
return idx
idx = body.find(_FUNC_CLOSE_TAG, idx + 1)
return -1
def _trim_param_value(val: str) -> str:
"""Trim the single wrapping newline the chat template adds around an XML
parameter value, preserving indentation inside VALUE (``str.strip()`` destroyed
code/diff argument indentation)."""
if val.startswith("\n"):
val = val[1:]
if val.endswith("\n"):
val = val[:-1]
return val
def _marker_coverage(content: str, markers) -> list[tuple[int, int]]:
"""Coverage ``[start, end]`` per marker, used to skip markers that are another
call's data. Closes pair to markers via a per-format stack so an inner close
is not mistaken for the outer's. Unbalanced braces cover to EOF; balanced with
a paired close cover through it (markers before the close are data); balanced
without one cover only the braces, so a later sibling is still recovered."""
n = len(content)
brace_regions = [(s, be) for (s, be, _k, _m) in markers if be >= 0]
events = [] # (position, order) with order 0 = braces-done, 1 = close marker
for idx, (_start, brace_end, _kind, _m) in enumerate(markers):
if brace_end >= 0:
events.append((brace_end, 0, _kind, idx))
for kind, close_re in (("json", _TC_END_TAG_RE), ("gemma", _TC_GEMMA_END_TAG_RE)):
for cm in close_re.finditer(content):
# A close inside another call's balanced braces is quoted data; it
# must not pop an earlier close-less marker and swallow a sibling.
if any(s < cm.start() < be for s, be in brace_regions):
continue
events.append((cm.start(), 1, kind, cm.end()))
events.sort(key = lambda e: (e[0], e[1]))
waiting = {"json": [], "gemma": []}
close_end_for: dict[int, int] = {}
for _pos, order, kind, payload in events:
if order == 0:
waiting[kind].append(payload) # marker index, now awaiting its close
elif waiting[kind]:
close_end_for[waiting[kind].pop()] = payload # innermost open marker closes here
coverage = []
for idx, (start, brace_end, _kind, _m) in enumerate(markers):
if brace_end < 0:
coverage.append((start, n))
elif idx in close_end_for:
coverage.append((start, close_end_for[idx]))
else:
coverage.append((start, brace_end))
return coverage
def _build_markers(content: str):
"""JSON/Gemma tool markers as ``(start, brace_end, kind, match)`` in document
order; ``brace_end < 0`` marks an unbalanced (to-EOF) open."""
markers = []
for start_re, gemma, kind in (
(_TC_JSON_START_RE, False, "json"),
(_TC_GEMMA_START_RE, True, "gemma"),
):
for m in start_re.finditer(content):
if _inside_open_parameter(content, m.start()):
continue
brace_end = _balanced_brace_end(content, m.end() - 1, gemma_quotes = gemma)
markers.append((m.start(), brace_end, kind, m))
markers.sort(key = lambda c: c[0])
return markers
def marker_coverage(content: str) -> list[tuple[int, int]]:
"""Coverage spans of JSON/Gemma tool markers so other parsers can treat markup
inside a marker's coverage (even a marker that failed to parse) as that call's
data rather than a sibling call."""
return _marker_coverage(content, _build_markers(content))
def parse_tool_calls_from_text(
@ -301,6 +634,7 @@ def parse_tool_calls_from_text(
*,
id_offset: int = 0,
allow_incomplete: bool = True,
enabled_tool_names = None,
with_spans: bool = False,
):
"""Parse OpenAI-format tool calls from model text.
@ -309,55 +643,61 @@ def parse_tool_calls_from_text(
<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>
[TOOL_CALLS]web_search{"query":"..."} (Mistral / Devstral fallback)
web_search[ARGS]{"query":"..."} (reasoning-model rehearsal)
A call rehearsed inside a ``<think>`` / ``[THINK]`` block is skipped, not
executed; the block is kept so a literal tag in a real argument is preserved.
With ``with_spans=True`` returns ``(tool_calls, spans)`` where ``spans[i]``
is the half-open ``(start, end)`` byte range of ``tool_calls[i]``'s markup
in ``content`` (including its close tag when present), so a caller can
remove exactly the parsed markup and keep every other byte intact.
"""
# Candidates starting inside a think block are rehearsals, skipped; blocks are kept, and a
# think marker opening inside a call is argument data (excluded from spans).
_think_spans = _think_spans_outside_tool_markup(content)
_think_starts = [s for s, _e in _think_spans]
def _in_think(pos: int) -> bool:
# Spans are ordered and non-overlapping; bisect gives O(log M) per candidate.
i = bisect.bisect_right(_think_starts, pos) - 1
return i >= 0 and _think_spans[i][0] <= pos < _think_spans[i][1]
tool_calls: list[dict] = []
call_spans: list[tuple] = []
# Collect every supported call format with spans, then emit in document
# order. A marker inside another call's argument string is data, not a
# separate executable call.
parsed_items = [] # (start, span_end, name, arguments)
candidates = [] # (start, brace_end, kind, match)
for m in _TC_JSON_START_RE.finditer(content):
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])
candidate_spans = [(s, e) for s, e, _kind, _m in candidates]
for idx, (start, end, kind, m) in enumerate(candidates):
if any(s <= start and end <= e for j, (s, e) in enumerate(candidate_spans) if j != idx):
# Collect JSON/Gemma markers; _marker_coverage decides nesting so a marker inside
# another call's coverage (even one that failed to parse) is data, not executed. A
# marker opening inside a think block is a rehearsal and is skipped.
parsed_items = [] # (start, span_end, name, arguments) in document order
markers = [mk for mk in _build_markers(content) if not _in_think(mk[0])]
coverage = _marker_coverage(content, markers)
for idx, (start, brace_end, kind, m) in enumerate(markers):
if any(s <= start < e for j, (s, e) in enumerate(coverage) if j != idx):
continue
if brace_end < 0:
continue # unclosed: not parseable; the fallback still excludes its XML
if not allow_incomplete:
tail = content[end + 1 :].lstrip()
tail = content[brace_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])
obj = json.loads(content[m.end() - 1 : brace_end + 1])
name = obj.get("name", "")
arguments = obj.get("arguments", {})
# Accept ``parameters`` alias for ``arguments`` (Llama-3.2 drift inside Hermes).
arguments = obj.get("arguments")
if arguments is None:
arguments = obj.get("parameters", {})
if isinstance(arguments, dict):
arguments = json.dumps(arguments)
else:
name = m.group(1)
arguments = json.dumps(_gemma_arguments_to_json(content[m.end() : end]))
arguments = json.dumps(_gemma_arguments_to_json(content[m.end() : brace_end]))
except (json.JSONDecodeError, ValueError):
continue
span_end = end + 1
span_end = brace_end + 1
close_re = _TC_END_TAG_RE if kind == "json" else _TC_GEMMA_END_TAG_RE
ws = len(content[span_end:]) - len(content[span_end:].lstrip())
close_m = close_re.match(content, span_end + ws)
@ -369,7 +709,8 @@ def parse_tool_calls_from_text(
fm
for fm in _TC_FUNC_START_RE.finditer(content)
if not _inside_open_parameter(content, fm.start())
and not any(s <= fm.start() <= e for s, e in candidate_spans)
and not _in_think(fm.start())
and not any(s <= fm.start() < e for s, e in coverage)
]
for idx, fm in enumerate(func_starts):
func_name = fm.group(1)
@ -382,7 +723,7 @@ def parse_tool_calls_from_text(
body_end = len(content)
body_end = min(body_end, next_func)
body = content[body_start:body_end]
close_idx = body.rfind(_FUNC_CLOSE_TAG)
close_idx = _func_close_index(content, body_start, body)
if close_idx >= 0:
span_end = body_start + close_idx + len(_FUNC_CLOSE_TAG)
body = body[:close_idx]
@ -404,7 +745,7 @@ def parse_tool_calls_from_text(
val = stripped_val[: -len(_PARAM_CLOSE_TAG)]
else:
val = _TC_PARAM_CLOSE_RE.sub("", val)
arguments[pm.group(1)] = val.strip()
arguments[pm.group(1)] = _trim_param_value(val)
else:
valid_params = True
for pidx, pm in enumerate(param_starts):
@ -422,7 +763,7 @@ def parse_tool_calls_from_text(
val = stripped_val[: -len(_PARAM_CLOSE_TAG)]
else:
val = _TC_PARAM_CLOSE_RE.sub("", val)
arguments[param_name] = val.strip()
arguments[param_name] = _trim_param_value(val)
if not valid_params:
continue
@ -444,19 +785,293 @@ def parse_tool_calls_from_text(
}
)
call_spans.append((start, span_end))
# Patterns 3+4: Mistral [TOOL_CALLS] and bare rehearsal via one balanced scan in document
# order, so a Mistral call and a rehearsal in one message both parse.
if not tool_calls:
for start, end, kind, m in _iter_bracket_spans(
content, enabled_tool_names = enabled_tool_names
):
if _in_think(start):
continue
# Extend the region over an immediately-following v11 closer so with_spans consumers strip it too.
closer = re.match(r"\s*\[/TOOL_CALLS\]", content[end:])
region_end = end + closer.end() if closer else end
if kind == "array":
# Decode elements individually (comma-tolerant): one json.loads of the whole
# body rejects the comma-less multi-call arrays Mistral/Ollama templates emit.
payload, item_ends = _decode_array_items(content, m.end(), end)
if not payload:
continue
# Tile the region so every byte belongs to exactly one span; a with_spans consumer
# keeps skipped bytes visible and strips promoted markup exactly once.
tile_start = start
last_span_idx = -1
for item_idx, item in enumerate(payload):
if not isinstance(item, dict) or "name" not in item:
continue
args = item.get("arguments", {})
if isinstance(args, str):
# ``arguments`` may itself be a JSON string (OpenAI spec).
try:
args = json.loads(args)
except (json.JSONDecodeError, ValueError):
pass
if not isinstance(args, (dict, str)):
# ``"arguments": null`` (or any non-object scalar) becomes {} like the
# <tool_call> path, not the string "null" auto-heal would mangle to
# a bogus {"query":"null"}.
args = {}
tool_calls.append(
{
"id": f"call_{id_offset + len(tool_calls)}",
"type": "function",
"function": {
"name": item.get("name", ""),
# A bare scalar string stays raw (like the <tool_call> path);
# json.dumps would double-encode it so the arg healer wraps
# "weather" with its literal quotes.
"arguments": args if isinstance(args, str) else json.dumps(args),
},
}
)
item_end = item_ends[item_idx] if item_idx < len(item_ends) else region_end
last_span_idx = len(call_spans)
call_spans.append((tile_start, item_end))
tile_start = item_end
if last_span_idx >= 0:
tile_start, _tile_end = call_spans[last_span_idx]
call_spans[last_span_idx] = (tile_start, region_end)
else:
try:
payload = json.loads(content[m.end() : end])
except (json.JSONDecodeError, ValueError):
continue
if not isinstance(payload, dict):
continue
tool_calls.append(
{
"id": f"call_{id_offset + len(tool_calls)}",
"type": "function",
"function": {
"name": m.group(1),
"arguments": json.dumps(payload),
},
}
)
call_spans.append((start, region_end))
if with_spans:
return tool_calls, call_spans
return tool_calls
def strip_tool_call_markup(text: str, *, final: bool = False) -> str:
def _strip_bracket_tag_calls(text: str, enabled_tool_names = None) -> str:
"""Strip complete [TOOL_CALLS] arrays / name / bare name[ARGS]{..} calls with one
balanced forward scan, so nested JSON args are removed whole (a fixed-depth regex
left two-level args behind). Truncated tails go to the caller's catch-all. Linear.
``enabled_tool_names`` gates the rehearsal form (inactive-name prose kept; None
strips every span)."""
if len(text) > _MAX_BRACKET_SCAN_CHARS:
return text
out: list[str] = []
cursor = 0
for start, end, _kind, _m in _iter_bracket_spans(text, enabled_tool_names = enabled_tool_names):
out.append(text[cursor:start])
cursor = end
out.append(text[cursor:])
return "".join(out)
def _tool_call_markup_spans(text: str) -> list[tuple[int, int]]:
"""Spans of tool-call markup, so a literal <think>/[THINK] inside a call's args is
stripped WITH the call, not kept as a reasoning block. Covers closed XML/bracket
calls and an unclosed XML call (run via allow_incomplete); without the open-ended
span the unclosed call's markup would leak after execution."""
# Skip a lazy closed-pair pattern whose close token is absent: its finditer would rescan
# to EOF from every opener (quadratic on a stream of unclosed openers).
spans = [
m.span()
for pat in _TOOL_CLOSED_PATS
if (_PAT_REQUIRED_TOKEN.get(pat) is None or _PAT_REQUIRED_TOKEN[pat] in text)
for m in pat.finditer(text)
]
spans.extend((start, end) for start, end, _kind, _m in _iter_bracket_spans(text))
# An unclosed opener is a real incomplete call only outside closed/bracket spans.
for pat in _TOOL_OPEN_XML_TAIL_PATS:
for m in pat.finditer(text):
if not any(s <= m.start() < e for s, e in spans):
spans.append(m.span())
return spans
def _think_spans_outside_tool_markup(text: str) -> list[tuple[int, int]]:
"""<think>/[THINK] block spans, minus any whose opening marker sits INSIDE a
tool-call span (that tag is argument data, not reasoning). Keeping it would drop a
real call after it as rehearsed and leak the call's markup. START tested only, so
a greedy unclosed <think> past the call is still that call's argument data."""
think_spans = [m.span() for m in _THINK_TAG_RE.finditer(text)]
call_spans = _tool_call_markup_spans(text)
# Prefilled reasoning: the template opens <think> in the prompt, so add a leading span
# (0..close) to skip calls rehearsed there; guarded so a stray close in a normal answer is safe.
close = _THINK_CLOSE_RE.search(text)
if close is not None:
opener = _THINK_OPEN_RE.search(text)
if (
(opener is None or close.start() < opener.start())
and not any(cs <= close.start() < ce for cs, ce in call_spans)
and any(cs >= close.end() for cs, ce in call_spans)
):
think_spans = [(0, close.end())] + think_spans
if not think_spans:
return think_spans
if not call_spans:
return think_spans
return [(s, e) for (s, e) in think_spans if not any(cs <= s < ce for cs, ce in call_spans)]
def strip_outside_think(text: str, strip_segment) -> str:
"""Apply ``strip_segment(segment, is_last)`` to visible text around <think>/[THINK]
blocks, preserving the blocks verbatim (tool-looking text inside is rehearsal).
``is_last`` is True only after the final block, so trailing-tail patterns apply
only there. Shared by every strip path so they stay consistent."""
# A think marker opening inside a complete call is argument text; excluding it lets the
# stripper see the whole call. START-tested, so an unclosed match stays argument data.
think_spans = _think_spans_outside_tool_markup(text)
if not think_spans:
return strip_segment(text, True)
pieces: list[str] = []
prev = 0
for s, e in think_spans:
pieces.append(strip_segment(text[prev:s], False))
pieces.append(text[s:e])
prev = e
pieces.append(strip_segment(text[prev:], True))
return "".join(pieces)
def _strip_gemma_native_spans(text: str, *, final: bool) -> str:
"""Remove complete Gemma-native spans, brace/quote-balanced so a literal
``<tool_call|>`` in a quoted argument cannot truncate the span. An incomplete
span is dropped to EOF when ``final``, else kept (still streaming)."""
out: list[str] = []
cursor = 0
for match in _TC_GEMMA_START_RE.finditer(text):
start = match.start()
if start < cursor:
continue
brace_end = _balanced_brace_end(text, match.end() - 1, gemma_quotes = True)
if brace_end < 0:
# Unbalanced: nothing completes from here on. Drop the rest if final,
# else keep it; stop either way (rescanning would be quadratic).
if final:
out.append(text[cursor:start])
cursor = len(text)
break
# Junk between } and <tool_call|> is malformed-call markup: strip through
# the close, keep text after it. No close anywhere means stop (linear).
close = _TC_GEMMA_END_TAG_RE.search(text, brace_end + 1)
if close is None:
if final:
out.append(text[cursor:start])
cursor = len(text)
break
out.append(text[cursor:start])
cursor = close.end()
out.append(text[cursor:])
return "".join(out)
def _gemma_span_ranges(text: str) -> list:
"""``(start, end)`` of each complete Gemma-native span; same walk as
``_strip_gemma_native_spans`` without stripping."""
ranges: list[tuple] = []
cursor = 0
for match in _TC_GEMMA_START_RE.finditer(text):
start = match.start()
if start < cursor:
continue
brace_end = _balanced_brace_end(text, match.end() - 1, gemma_quotes = True)
if brace_end < 0:
break
close = _TC_GEMMA_END_TAG_RE.search(text, brace_end + 1)
if close is None:
break
ranges.append((start, close.end()))
cursor = close.end()
return ranges
def _strip_closed_blocks_outside_gemma(text: str) -> str:
"""Closed JSON/function pre-pass that skips matches starting inside a complete
Gemma span: deleting across the span boundary would mangle the Gemma close and
truncate the tail. A skipped match resumes at the covering span's end, so a
real function-XML call after the span is still stripped."""
ranges = _gemma_span_ranges(text)
if not ranges:
return strip_tool_patterns(text, _TOOL_CLOSED_BLOCK_PATS)
for pat in _TOOL_CLOSED_BLOCK_PATS:
token = _PAT_REQUIRED_TOKEN.get(pat)
if token is not None and token not in text:
continue
out: list[str] = []
pos = 0
while True:
m = pat.search(text, pos)
if m is None:
out.append(text[pos:])
break
covering = next((r for r in ranges if r[0] <= m.start() < r[1]), None)
if covering is not None:
out.append(text[pos : covering[1]])
pos = covering[1]
continue
out.append(text[pos : m.start()])
pos = m.end()
new_text = "".join(out)
if new_text != text:
text = new_text
ranges = _gemma_span_ranges(text)
return text
def _strip_markup_segment(
text: str,
*,
final: bool,
enabled_tool_names = None,
) -> str:
# Bracket-tag calls (Mistral/rehearsal) first via balanced scan (any nesting depth,
# rehearsal name-gated); then the quote-aware Gemma-native passes so a literal
# <tool_call|> in an argument cannot truncate a block; finally the regex XML/tail sweeps.
text = _strip_bracket_tag_calls(text, enabled_tool_names = enabled_tool_names)
text = _strip_closed_blocks_outside_gemma(text)
text = _strip_gemma_native_spans(text, final = final)
patterns = _TOOL_ALL_PATS if final else _TOOL_CLOSED_PATS
return apply_tool_strip_patterns(text, patterns, enabled_tool_names = enabled_tool_names)
def strip_tool_call_markup(
text: str,
*,
final: bool = False,
enabled_tool_names = None,
) -> str:
"""Strip tool-call XML markup from text.
When ``final`` is False, only fully closed tool-call blocks are removed.
When ``final`` is True, trailing incomplete tool-call blocks are removed
too, and the result is stripped of surrounding whitespace.
``<think>`` / ``[THINK]`` reasoning is preserved verbatim (see
``strip_outside_think``); the trailing-tail patterns apply only after the
last block. ``enabled_tool_names`` keeps an inactive-name ``foo[ARGS]{..}``
example visible (it is prose, not a call) so display cleanup matches detection.
"""
patterns = _TOOL_ALL_PATS if final else _TOOL_CLOSED_PATS
for pat in patterns:
text = pat.sub("", text)
return text.strip() if final else text
result = strip_outside_think(
text,
lambda seg, is_last: _strip_markup_segment(
seg, final = final and is_last, enabled_tool_names = enabled_tool_names
),
)
return result.strip() if final else result

View file

@ -62,7 +62,6 @@ from loggers import get_logger
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Callable
from dataclasses import dataclass
import pandas as pd
from datasets import Dataset
from utils.datasets.cache_safe import load_dataset_cache_safe as load_dataset
@ -71,7 +70,7 @@ from core.inference.llama_cpp import _hf_offline_if_dns_dead
from utils.models import is_vision_model, detect_audio_type
from utils.models.model_config import _env_offline
from utils.datasets import format_and_template_dataset
from utils.datasets import MODEL_TO_TEMPLATE_MAPPER, TEMPLATE_TO_RESPONSES_MAPPER
from utils.datasets.completion_masking import apply_completion_masking
from utils.datasets.iterable import is_streaming_dataset as detect_streaming_dataset
from utils.datasets.raw_text import prepare_raw_text_dataset, resolve_column_names
from utils.paths import (
@ -86,6 +85,11 @@ from utils.native_path_leases import child_env_without_native_path_secret
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
)
from .training import (
TrainingProgress,
create_mlx_trainer_adapter,
should_use_mlx_training_backend,
)
logger = get_logger(__name__)
@ -104,31 +108,16 @@ def _build_report_targets(training_args) -> list[str] | str:
return report_to or "none"
@dataclass
class TrainingProgress:
"""Training progress tracking"""
epoch: float = 0
step: int = 0
total_steps: int = 0
loss: Optional[float] = None
learning_rate: Optional[float] = None
is_training: bool = False
is_completed: bool = False
error: Optional[str] = None
status_message: str = "Ready to train" # Current stage
elapsed_seconds: Optional[float] = None
eta_seconds: Optional[float] = None
grad_norm: Optional[float] = None
num_tokens: Optional[int] = None
eval_loss: Optional[float] = None
class UnslothTrainer:
"""
Unsloth Training Backend
"""
def __new__(cls, *args, **kwargs):
if cls is UnslothTrainer and should_use_mlx_training_backend():
return create_mlx_trainer_adapter(*args, **kwargs)
return super().__new__(cls)
def __init__(self):
self.model = None
self.tokenizer = None
@ -3466,8 +3455,6 @@ class UnslothTrainer:
# ========== TRAIN ON RESPONSES ONLY ==========
# Raw-text datasets always train on all tokens.
instruction_part = None
response_part = None
is_cpt = training_args.get("is_cpt", False)
train_on_responses_enabled = (
False
@ -3484,113 +3471,93 @@ class UnslothTrainer:
# DeepSeek OCR handles this internally in its collator, so skip
# Audio VLM handles label masking in its collator, so skip
# Markers auto-detected from the chat template first, manual table
# as fallback; gpt-oss stays on its manual markers. See
# apply_completion_masking.
if (
train_on_responses_enabled
and not self.is_audio_vlm
and not self.is_audio
and not (is_deepseek_ocr or dataset_final_format == "alpaca")
):
try:
logger.info("Configuring train on responses only...\n")
from unsloth.chat_templates import train_on_responses_only
# Template mapping for this model
model_name_lower = self.model_name.lower()
logger.info("Configuring train on responses only...\n")
if model_name_lower in MODEL_TO_TEMPLATE_MAPPER:
template_name = MODEL_TO_TEMPLATE_MAPPER[model_name_lower]
logger.info(f"Detected template: {template_name}\n")
def _notify(level, message):
if level == "warning":
logger.warning(message)
else:
logger.info(f"{message}\n")
if template_name in TEMPLATE_TO_RESPONSES_MAPPER:
instruction_part = TEMPLATE_TO_RESPONSES_MAPPER[template_name][
"instruction"
]
response_part = TEMPLATE_TO_RESPONSES_MAPPER[template_name]["response"]
# No try/except: the helper handles detection failures and
# double misses itself, so an exception here is a real masking
# failure that must fail the run, not silently train on full
# sequences.
self.trainer, masking_applied = apply_completion_masking(
self.trainer,
self.model_name,
train_on_responses_only,
num_proc = config_args["dataset_num_proc"],
notify = _notify,
)
logger.info(f"Instruction marker: {instruction_part[:50]}...\n")
logger.info(f"Response marker: {response_part[:50]}...\n")
if not masking_applied:
train_on_responses_enabled = False
if masking_applied:
try:
# ── Safety net: check if all samples were filtered out ──
# train_on_responses_only masks non-response tokens with -100; a
# row becomes all -100 (Unsloth drops it) when the response
# template is not found in the formatted text. Usually a
# dataset/template mismatch (already-formatted data, or 'Train on
# completions' on data that doesn't match the model's chat
# template); only sometimes max_seq_length truncating the response
# away. Skip this len()-based check for streaming.
if detect_streaming_dataset(self.trainer.train_dataset):
logger.info("Skipping post-filter length check for streaming dataset\n")
else:
logger.info(
f"No response mapping found for template: {template_name}\n"
filtered_len = len(self.trainer.train_dataset)
original_dataset_obj = (
dataset["dataset"] if isinstance(dataset, dict) else dataset
)
train_on_responses_enabled = False
else:
logger.info(f"No template mapping found for model: {self.model_name}\n")
train_on_responses_enabled = False
except Exception as e:
logger.warning(f"Could not configure train on responses: {e}")
train_on_responses_enabled = False
# Apply train on responses only if we have valid parts
if (
train_on_responses_enabled
and instruction_part
and response_part
and not self.is_audio_vlm
and not self.is_audio
and not (is_deepseek_ocr or dataset_final_format == "alpaca")
):
try:
from unsloth.chat_templates import train_on_responses_only
self.trainer = train_on_responses_only(
self.trainer,
instruction_part = instruction_part,
response_part = response_part,
num_proc = config_args["dataset_num_proc"],
)
logger.info("Train on responses only configured successfully\n")
# ── Safety net: check if all samples were filtered out ──
# train_on_responses_only masks non-response tokens with -100;
# a row becomes all -100 (and Unsloth drops it) when the response
# template is not found in the formatted text. That is usually a
# dataset/template mismatch (already-formatted data, or 'Train on
# completions' applied to data that doesn't match the model's chat
# template), and only sometimes max_seq_length truncating the
# response away. Skip this len()-based check for streaming.
if detect_streaming_dataset(self.trainer.train_dataset):
logger.info("Skipping post-filter length check for streaming dataset\n")
else:
filtered_len = len(self.trainer.train_dataset)
original_dataset_obj = (
dataset["dataset"] if isinstance(dataset, dict) else dataset
)
original_len = len(original_dataset_obj)
dropped = original_len - filtered_len
drop_pct = round(100 * dropped / original_len, 1) if original_len > 0 else 0
if filtered_len == 0 or drop_pct > 30:
max_seq = training_args.get("max_seq_length", 2048)
error_msg = (
f"{dropped}/{original_len} samples ({drop_pct}%) were "
f"dropped after applying 'Train on completions': after "
f"masking, those rows had no trainable response tokens "
f"left. The usual cause is that this model's response "
f"template was not found in the formatted samples, so "
f"every token was masked out. That typically means the "
f"dataset is already formatted, or its structure does "
f"not match the model's chat template, so 'Train on "
f"completions' should be turned off for this dataset. "
f"Less commonly, a max_seq_length ({max_seq}) shorter "
f"than the prompt can truncate the response away; only "
f"raise it if your samples are actually longer than that."
original_len = len(original_dataset_obj)
dropped = original_len - filtered_len
drop_pct = (
round(100 * dropped / original_len, 1) if original_len > 0 else 0
)
logger.error(error_msg)
self._update_progress(error = error_msg, is_training = False)
return
if dropped > 0:
logger.info(
f"⚠️ {dropped}/{original_len} samples "
f"({drop_pct}%) were dropped (all labels "
f"masked). {filtered_len} samples remain.\n"
)
logger.info(f"Post-filter dataset size: {filtered_len} samples\n")
if filtered_len == 0 or drop_pct > 30:
max_seq = training_args.get("max_seq_length", 2048)
error_msg = (
f"{dropped}/{original_len} samples ({drop_pct}%) were "
f"dropped after applying 'Train on completions': after "
f"masking, those rows had no trainable response tokens "
f"left. The usual cause is that this model's response "
f"template was not found in the formatted samples, so "
f"every token was masked out. That typically means the "
f"dataset is already formatted, or its structure does "
f"not match the model's chat template, so 'Train on "
f"completions' should be turned off for this dataset. "
f"Less commonly, a max_seq_length ({max_seq}) shorter "
f"than the prompt can truncate the response away; only "
f"raise it if your samples are actually longer than that."
)
logger.error(error_msg)
self._update_progress(error = error_msg, is_training = False)
return
except Exception as e:
logger.warning(f"Failed to apply train on responses only: {e}")
train_on_responses_enabled = False
if dropped > 0:
logger.info(
f"⚠️ {dropped}/{original_len} samples "
f"({drop_pct}%) were dropped (all labels "
f"masked). {filtered_len} samples remain.\n"
)
logger.info(f"Post-filter dataset size: {filtered_len} samples\n")
except Exception as e:
logger.warning(f"Post-masking dataset size check failed: {e}")
else:
if train_on_responses_enabled and is_deepseek_ocr:
logger.info("Train on responses handled by DeepSeek OCR collator\n")

View file

@ -14,17 +14,19 @@ import json as _json
import math
import multiprocessing as mp
import os
import platform
import queue
import re
import shutil
import threading
import time
import traceback
import structlog
from datetime import datetime, timezone
from loggers import get_logger
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Optional, Tuple, Any, TYPE_CHECKING
from typing import Optional, Tuple, Any, Callable, Union, TYPE_CHECKING
if TYPE_CHECKING:
import matplotlib.pyplot as plt
@ -98,6 +100,107 @@ def _coerce_optional_nonneg_float(name: str, value):
return coerced
def is_apple_silicon_training_platform() -> bool:
return platform.system() == "Darwin" and platform.machine() == "arm64"
def is_mlx_training_device(device: Any) -> bool:
return (
str(device).lower() == "mlx"
or str(device).lower().endswith(".mlx")
or getattr(device, "name", "").lower() == "mlx"
)
def should_use_mlx_training_backend(*, device: Optional[Any] = None) -> bool:
if device is not None:
return is_mlx_training_device(device)
return is_apple_silicon_training_platform()
def _build_training_worker_config(values: dict[str, Any]) -> dict[str, Any]:
"""Build the normalized worker config shared by Studio and the CLI adapter."""
config = {
"model_name": values["model_name"],
"project_name": values.get("project_name"),
"training_type": values.get("training_type", "LoRA/QLoRA"),
"hf_token": values.get("hf_token", ""),
"load_in_4bit": values.get("load_in_4bit", True),
"max_seq_length": values.get("max_seq_length", 2048),
"vision_image_size": values.get("vision_image_size"),
"hf_dataset": values.get("hf_dataset", ""),
"local_datasets": values.get("local_datasets"),
"local_eval_datasets": values.get("local_eval_datasets"),
"format_type": values.get("format_type", ""),
"subset": values.get("subset"),
"train_split": values.get("train_split", "train"),
"eval_split": values.get("eval_split"),
"eval_steps": values.get("eval_steps", 0.00),
"dataset_streaming": values.get("dataset_streaming", False),
"dataset_slice_start": values.get("dataset_slice_start"),
"dataset_slice_end": values.get("dataset_slice_end"),
"custom_format_mapping": values.get("custom_format_mapping"),
"is_dataset_image": values.get("is_dataset_image", False),
"is_dataset_audio": values.get("is_dataset_audio", False),
"is_embedding": values.get("is_embedding", False),
"num_epochs": values.get("num_epochs", 3),
"learning_rate": values.get("learning_rate", "2e-4"),
"embedding_learning_rate": values.get("embedding_learning_rate"),
"batch_size": values.get("batch_size", 2),
"gradient_accumulation_steps": values.get("gradient_accumulation_steps", 4),
"warmup_steps": values.get("warmup_steps"),
"warmup_ratio": values.get("warmup_ratio"),
"max_steps": values.get("max_steps", 0),
"save_steps": values.get("save_steps", 0),
"weight_decay": values.get("weight_decay", 0.001),
"max_grad_norm": values.get("max_grad_norm", 0.0),
"max_grad_value": _coerce_optional_nonneg_float(
"max_grad_value", values.get("max_grad_value")
),
"max_grad_leaf_norm": _coerce_optional_nonneg_float(
"max_grad_leaf_norm", values.get("max_grad_leaf_norm")
),
"cast_norm_output_to_input_dtype": _coerce_optional_bool(
values.get("cast_norm_output_to_input_dtype"), True
),
"random_seed": _coerce_seed(values.get("random_seed")),
"packing": values.get("packing", False),
"optim": values.get("optim", "adamw_8bit"),
"lr_scheduler_type": values.get("lr_scheduler_type", "linear"),
"use_lora": values.get("use_lora", True),
"lora_r": values.get("lora_r", 16),
"lora_alpha": values.get("lora_alpha", 16),
"lora_dropout": values.get("lora_dropout", 0.0),
"target_modules": values.get("target_modules"),
"gradient_checkpointing": values.get("gradient_checkpointing", "unsloth"),
"use_rslora": values.get("use_rslora", False),
"use_loftq": values.get("use_loftq", False),
"train_on_completions": values.get("train_on_completions", False),
"finetune_vision_layers": values.get("finetune_vision_layers", True),
"finetune_language_layers": values.get("finetune_language_layers", True),
"finetune_attention_modules": values.get("finetune_attention_modules", True),
"finetune_mlp_modules": values.get("finetune_mlp_modules", True),
"enable_wandb": values.get("enable_wandb", False),
"wandb_token": values.get("wandb_token"),
"wandb_project": values.get("wandb_project", "unsloth-training"),
"enable_tensorboard": values.get("enable_tensorboard", False),
"tensorboard_dir": values.get("tensorboard_dir", "runs"),
"resume_from_checkpoint": values.get("resume_from_checkpoint"),
"trust_remote_code": values.get("trust_remote_code", False),
"approved_remote_code_fingerprint": values.get("approved_remote_code_fingerprint"),
"subject": values.get("subject"),
"gpu_ids": values.get("gpu_ids"),
"s3_config": values.get("s3_config"),
"disable_xet": values.get("disable_xet", False),
}
for key in ("output_dir", "allow_external_output_dir"):
if key in values:
config[key] = values.get(key)
if config["training_type"] == "Full Finetuning":
config["load_in_4bit"] = False
return config
_HF_TMP_CHECKPOINT_RE = re.compile(r"^tmp-checkpoint-\d+$")
@ -133,7 +236,7 @@ def _s3_dataset_name(s3_dataset: Any) -> Optional[str]:
return f"s3://{bucket}/{prefix}" if prefix else f"s3://{bucket}"
def _cleanup_cancelled_checkpoints(output_dir: str | os.PathLike) -> None:
def _cleanup_cancelled_checkpoints(output_dir: Union[str, os.PathLike]) -> None:
"""Remove only HF Trainer ``tmp-checkpoint-<step>/`` partials after a cancel.
Completed ``checkpoint-<int>/`` dirs survive. Symlinked output_dir / children
@ -183,7 +286,7 @@ PLOT_HEIGHT = 3.5
@dataclass
class TrainingProgress:
"""Mirror of trainer.TrainingProgress so the parent never imports heavy ML modules."""
"""Shared training progress payload for Studio and backend-aware trainers."""
epoch: float = 0
step: int = 0
@ -200,6 +303,423 @@ class TrainingProgress:
num_tokens: Optional[int] = None
eval_loss: Optional[float] = None
peak_memory_gb: Optional[float] = None
output_dir: Optional[str] = None
class _MLXTrainerAdapter:
"""Adapts the legacy UnslothTrainer API to the shared Studio MLX worker path."""
def __init__(self):
self.model = None
self.tokenizer = None
self.trainer = None
self.training_thread = None
self.training_progress = TrainingProgress()
self.progress_callbacks: list[Callable[[TrainingProgress], None]] = []
self.is_training = False
self.should_stop = False
self.save_on_stop = True
self.load_in_4bit = True
self.output_dir = None
self.is_cpt = False
self.is_vlm = False
self.is_audio = False
self.is_audio_vlm = False
self.model_name = None
self.max_seq_length = None
self._model_config: dict[str, Any] = {}
self._peft_config: dict[str, Any] = {}
self._dataset_config: dict[str, Any] = {}
self._event_queue: Optional[queue.Queue] = None
self._stop_queue: Optional[queue.Queue] = None
self._pump_thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
def _activate_transformers_for_model(self, model_name: str, hf_token: Optional[str]) -> None:
try:
from utils.transformers_version import activate_transformers_for_subprocess
activate_transformers_for_subprocess(model_name, hf_token)
except Exception as exc:
logger.warning("MLX trainer adapter Transformers activation failed", error = str(exc))
def add_progress_callback(self, callback: Callable[[TrainingProgress], None]):
self.progress_callbacks.append(callback)
def _update_progress(self, **kwargs):
with self._lock:
for key, value in kwargs.items():
if hasattr(self.training_progress, key):
setattr(self.training_progress, key, value)
progress = self.training_progress
for callback in self.progress_callbacks:
try:
callback(progress)
except Exception:
pass
def load_model(
self,
model_name: str,
max_seq_length: int = 2048,
load_in_4bit: bool = True,
hf_token: Optional[str] = None,
is_dataset_image: bool = False,
is_dataset_audio: bool = False,
trust_remote_code: bool = False,
full_finetuning: bool = False,
gpu_ids: Optional[list[int]] = None,
) -> bool:
self.model_name = model_name
self.max_seq_length = max_seq_length
self.load_in_4bit = load_in_4bit
self._audio_type = None
self._activate_transformers_for_model(model_name, hf_token)
try:
from utils.models import detect_audio_type, is_vision_model
self._audio_type = detect_audio_type(model_name, hf_token)
if self._audio_type == "audio_vlm":
self.is_audio = False
self.is_audio_vlm = bool(is_dataset_audio)
self._audio_type = None
else:
self.is_audio = self._audio_type is not None
self.is_audio_vlm = False
vision = is_vision_model(model_name, hf_token = hf_token) if not self.is_audio else False
self.is_vlm = not self.is_audio_vlm and vision and bool(is_dataset_image)
except Exception as exc:
logger.warning("MLX trainer adapter model type detection failed", error = str(exc))
self.is_vlm = False
self.is_audio = False
self.is_audio_vlm = False
self.model = object()
self.tokenizer = object()
self._model_config = {
"model_name": model_name,
"max_seq_length": max_seq_length,
"load_in_4bit": load_in_4bit,
"hf_token": hf_token or "",
"is_dataset_image": bool(is_dataset_image),
"is_dataset_audio": bool(is_dataset_audio),
"trust_remote_code": bool(trust_remote_code),
"gpu_ids": gpu_ids,
}
self._update_progress(
is_training = False,
is_completed = False,
error = None,
step = 0,
loss = 0.0,
epoch = 0,
status_message = f"Queued MLX model load: {model_name}",
)
return True
def prepare_model_for_training(
self,
use_lora: bool = True,
finetune_vision_layers: bool = True,
finetune_language_layers: bool = True,
finetune_attention_modules: bool = True,
finetune_mlp_modules: bool = True,
target_modules: Optional[Union[list, str]] = None,
lora_r: int = 16,
lora_alpha: int = 16,
lora_dropout: float = 0.0,
use_gradient_checkpointing: Union[str, bool] = "unsloth",
use_rslora: bool = False,
use_loftq: bool = False,
) -> bool:
self._peft_config = {
"use_lora": bool(use_lora),
"lora_r": lora_r,
"lora_alpha": lora_alpha,
"lora_dropout": lora_dropout,
"target_modules": target_modules,
"gradient_checkpointing": use_gradient_checkpointing,
"use_rslora": bool(use_rslora),
"use_loftq": bool(use_loftq),
"finetune_vision_layers": bool(finetune_vision_layers),
"finetune_language_layers": bool(finetune_language_layers),
"finetune_attention_modules": bool(finetune_attention_modules),
"finetune_mlp_modules": bool(finetune_mlp_modules),
}
self._update_progress(status_message = "Queued MLX training setup")
return True
def load_and_format_dataset(
self,
dataset_source: Optional[str],
format_type: str = "auto",
local_datasets: Optional[list[str]] = None,
local_eval_datasets: Optional[list[str]] = None,
custom_format_mapping: Optional[dict[str, Any]] = None,
subset: Optional[str] = None,
train_split: str = "train",
eval_split: Optional[str] = None,
dataset_streaming: bool = False,
eval_steps: float = 0.00,
dataset_slice_start: Optional[int] = None,
dataset_slice_end: Optional[int] = None,
is_cpt: bool = False,
s3_config: dict = None,
) -> Optional[tuple]:
self._dataset_config = {
"hf_dataset": dataset_source or "",
"local_datasets": local_datasets,
"local_eval_datasets": local_eval_datasets,
"format_type": format_type or "",
"custom_format_mapping": custom_format_mapping,
"subset": subset,
"train_split": train_split or "train",
"eval_split": eval_split,
"dataset_streaming": bool(dataset_streaming),
"eval_steps": eval_steps or 0.0,
"dataset_slice_start": dataset_slice_start,
"dataset_slice_end": dataset_slice_end,
"s3_config": s3_config,
}
self.is_cpt = bool(is_cpt)
self._update_progress(status_message = "Queued MLX dataset load")
return ({"dataset": [], "final_format": "deferred_mlx_cli", "success": True}, None)
def start_training(
self,
dataset = None,
eval_dataset = None,
**training_args,
) -> bool:
if self.is_training and self.training_thread and self.training_thread.is_alive():
return False
if self._pump_thread and self._pump_thread.is_alive():
self._pump_thread.join(timeout = 2.0)
if self._pump_thread.is_alive():
self._update_progress(error = "Previous training event pump is still finalizing")
return False
if not self._model_config:
self._update_progress(error = "Model not loaded")
return False
if not self._dataset_config:
self._update_progress(error = "Dataset not loaded")
return False
if self.is_cpt:
self._update_progress(
error = "Continued Pretraining is not supported for MLX training yet.",
is_training = False,
is_completed = False,
)
return False
config = self._build_worker_config(training_args)
event_queue = queue.Queue()
stop_queue = queue.Queue()
self._event_queue = event_queue
self._stop_queue = stop_queue
self.should_stop = False
self.is_training = True
self.training_progress = TrainingProgress(
is_training = True,
status_message = "Initializing MLX training...",
)
self.training_thread = threading.Thread(
target = self._run_training_thread,
args = (config, event_queue, stop_queue),
daemon = True,
)
self._pump_thread = threading.Thread(
target = self._pump_events,
args = (event_queue, self.training_thread),
daemon = True,
)
self.training_thread.start()
self._pump_thread.start()
return True
def _build_worker_config(self, training_args: dict[str, Any]) -> dict[str, Any]:
peft = {
"use_lora": True,
"lora_r": 16,
"lora_alpha": 16,
"lora_dropout": 0.0,
"target_modules": None,
"gradient_checkpointing": "unsloth",
"use_rslora": False,
"use_loftq": False,
"finetune_vision_layers": True,
"finetune_language_layers": True,
"finetune_attention_modules": True,
"finetune_mlp_modules": True,
**self._peft_config,
}
output_dir = training_args.get("output_dir")
if output_dir:
output_dir = os.path.abspath(os.path.expanduser(str(output_dir)))
values = {
**self._model_config,
**self._dataset_config,
**training_args,
"training_type": (
"Continued Pretraining"
if self.is_cpt
else "LoRA/QLoRA"
if peft["use_lora"]
else "Full Finetuning"
),
**peft,
"output_dir": output_dir,
"allow_external_output_dir": bool(output_dir),
}
config = _build_training_worker_config(values)
config["resolved_gpu_ids"] = None
config["gpu_selection"] = None
return config
def _run_training_thread(
self, config: dict[str, Any], event_queue: queue.Queue, stop_queue: queue.Queue
):
try:
self._run_mlx_worker(config, event_queue, stop_queue)
except Exception as exc:
if event_queue is not None:
event_queue.put(
{
"type": "error",
"error": str(exc),
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
}
)
def _run_mlx_worker(
self, config: dict[str, Any], event_queue: queue.Queue, stop_queue: queue.Queue
):
from .worker import run_mlx_training_process
run_mlx_training_process(
event_queue = event_queue,
stop_queue = stop_queue,
config = config,
)
def _pump_events(self, event_queue: queue.Queue, training_thread: threading.Thread):
while True:
event = None
try:
event = event_queue.get(timeout = 0.25)
except queue.Empty:
pass
if event is not None:
self._handle_event(event)
continue
if not training_thread.is_alive():
self._drain_events(event_queue)
with self._lock:
if self.training_progress.is_training:
self.training_progress.is_training = False
if self.should_stop:
self.training_progress.status_message = "Training stopped."
elif (
not self.training_progress.error
and not self.training_progress.is_completed
):
self.training_progress.error = "Training process exited unexpectedly"
self.is_training = False
self._event_queue = None
self._stop_queue = None
return
def _drain_events(self, event_queue: Optional[queue.Queue] = None):
event_queue = event_queue or self._event_queue
if event_queue is None:
return
while True:
try:
self._handle_event(event_queue.get_nowait())
except queue.Empty:
return
def _handle_event(self, event: dict[str, Any]):
etype = event.get("type")
if etype == "status":
self._update_progress(
status_message = event.get("status_message") or event.get("message") or ""
)
return
if etype == "progress":
self._update_progress(
step = event.get("step", self.training_progress.step),
epoch = event.get("epoch", self.training_progress.epoch),
loss = event.get("loss", self.training_progress.loss),
learning_rate = event.get("learning_rate", self.training_progress.learning_rate),
total_steps = event.get("total_steps", self.training_progress.total_steps),
elapsed_seconds = event.get(
"elapsed_seconds",
self.training_progress.elapsed_seconds,
),
eta_seconds = event.get("eta_seconds", self.training_progress.eta_seconds),
grad_norm = event.get("grad_norm", self.training_progress.grad_norm),
num_tokens = event.get("num_tokens", self.training_progress.num_tokens),
eval_loss = event.get("eval_loss", self.training_progress.eval_loss),
peak_memory_gb = event.get("peak_memory_gb", self.training_progress.peak_memory_gb),
)
return
if etype == "complete":
status_message = event.get("status_message") or "Training completed"
output_dir = event.get("output_dir")
was_cancelled = self.should_stop or status_message.strip().lower() in {
"training cancelled",
"training stopped",
}
self.output_dir = output_dir
self._update_progress(
is_training = False,
is_completed = not was_cancelled,
error = None,
status_message = status_message,
output_dir = output_dir,
)
self.is_training = False
return
if etype == "error":
self._update_progress(
is_training = False,
is_completed = False,
error = event.get("error") or event.get("message") or "Training failed",
)
self.is_training = False
return
def stop_training(self, save: bool = True):
self.should_stop = True
self.save_on_stop = bool(save)
if self._stop_queue is not None:
self._stop_queue.put({"type": "stop", "save": save})
status_message = (
"Stopping training and saving checkpoint..." if save else "Cancelling training..."
)
self._update_progress(status_message = status_message)
return True
def get_training_progress(self) -> TrainingProgress:
pump_thread = self._pump_thread
training_thread = self.training_thread
if (
pump_thread is not None
and pump_thread.is_alive()
and (training_thread is None or not training_thread.is_alive())
and threading.current_thread() is not pump_thread
):
pump_thread.join(timeout = 5.0)
if pump_thread is None or not pump_thread.is_alive():
self._drain_events()
with self._lock:
return replace(self.training_progress)
def create_mlx_trainer_adapter(*args, **kwargs):
return _MLXTrainerAdapter(*args, **kwargs)
class TrainingBackend:
@ -296,86 +816,7 @@ class TrainingBackend:
# treat this fresh setup as a recoverable death.
self._pump_running = False
# Build config dict for the subprocess
config = {
"model_name": kwargs["model_name"],
"project_name": kwargs.get("project_name"),
"training_type": kwargs.get("training_type", "LoRA/QLoRA"),
"hf_token": kwargs.get("hf_token", ""),
"load_in_4bit": kwargs.get("load_in_4bit", True),
"max_seq_length": kwargs.get("max_seq_length", 2048),
"vision_image_size": kwargs.get("vision_image_size"),
"hf_dataset": kwargs.get("hf_dataset", ""),
"local_datasets": kwargs.get("local_datasets"),
"local_eval_datasets": kwargs.get("local_eval_datasets"),
"format_type": kwargs.get("format_type", ""),
"subset": kwargs.get("subset"),
"train_split": kwargs.get("train_split", "train"),
"eval_split": kwargs.get("eval_split"),
"eval_steps": kwargs.get("eval_steps", 0.00),
"dataset_streaming": kwargs.get("dataset_streaming", False),
"dataset_slice_start": kwargs.get("dataset_slice_start"),
"dataset_slice_end": kwargs.get("dataset_slice_end"),
"custom_format_mapping": kwargs.get("custom_format_mapping"),
"is_dataset_image": kwargs.get("is_dataset_image", False),
"is_dataset_audio": kwargs.get("is_dataset_audio", False),
"is_embedding": kwargs.get("is_embedding", False),
"num_epochs": kwargs.get("num_epochs", 3),
"learning_rate": kwargs.get("learning_rate", "2e-4"),
"embedding_learning_rate": kwargs.get("embedding_learning_rate"),
"batch_size": kwargs.get("batch_size", 2),
"gradient_accumulation_steps": kwargs.get("gradient_accumulation_steps", 4),
"warmup_steps": kwargs.get("warmup_steps"),
"warmup_ratio": kwargs.get("warmup_ratio"),
"max_steps": kwargs.get("max_steps", 0),
"save_steps": kwargs.get("save_steps", 0),
"weight_decay": kwargs.get("weight_decay", 0.001),
"max_grad_norm": kwargs.get("max_grad_norm", 0.0),
"max_grad_value": _coerce_optional_nonneg_float(
"max_grad_value", kwargs.get("max_grad_value")
),
"max_grad_leaf_norm": _coerce_optional_nonneg_float(
"max_grad_leaf_norm", kwargs.get("max_grad_leaf_norm")
),
"cast_norm_output_to_input_dtype": _coerce_optional_bool(
kwargs.get("cast_norm_output_to_input_dtype"), True
),
# MLX/CUDA/embedding workers need an int (transformers.set_seed(None) raises).
"random_seed": _coerce_seed(kwargs.get("random_seed")),
"packing": kwargs.get("packing", False),
"optim": kwargs.get("optim", "adamw_8bit"),
"lr_scheduler_type": kwargs.get("lr_scheduler_type", "linear"),
"use_lora": kwargs.get("use_lora", True),
"lora_r": kwargs.get("lora_r", 16),
"lora_alpha": kwargs.get("lora_alpha", 16),
"lora_dropout": kwargs.get("lora_dropout", 0.0),
"target_modules": kwargs.get("target_modules"),
"gradient_checkpointing": kwargs.get("gradient_checkpointing", "unsloth"),
"use_rslora": kwargs.get("use_rslora", False),
"use_loftq": kwargs.get("use_loftq", False),
"train_on_completions": kwargs.get("train_on_completions", False),
"finetune_vision_layers": kwargs.get("finetune_vision_layers", True),
"finetune_language_layers": kwargs.get("finetune_language_layers", True),
"finetune_attention_modules": kwargs.get("finetune_attention_modules", True),
"finetune_mlp_modules": kwargs.get("finetune_mlp_modules", True),
"enable_wandb": kwargs.get("enable_wandb", False),
"wandb_token": kwargs.get("wandb_token"),
"wandb_project": kwargs.get("wandb_project", "unsloth-training"),
"enable_tensorboard": kwargs.get("enable_tensorboard", False),
"tensorboard_dir": kwargs.get("tensorboard_dir", "runs"),
"resume_from_checkpoint": kwargs.get("resume_from_checkpoint"),
"trust_remote_code": kwargs.get("trust_remote_code", False),
"approved_remote_code_fingerprint": kwargs.get("approved_remote_code_fingerprint"),
"subject": kwargs.get("subject"),
"gpu_ids": kwargs.get("gpu_ids"),
"s3_config": kwargs.get("s3_config"),
# Flipped to True only by the HTTP-fallback respawn after a stall.
"disable_xet": kwargs.get("disable_xet", False),
}
# Full finetuning always runs in 16-bit; LoRA/QLoRA/CPT keep the request.
if config["training_type"] == "Full Finetuning":
config["load_in_4bit"] = False
config = _build_training_worker_config(kwargs)
# Split GPU validation from placement around the VRAM hook:
# * Explicit gpu_ids are validated here (raises -> the route returns 400
@ -401,7 +842,7 @@ class TrainingBackend:
)
defer_auto_selection = False
if _hw.DEVICE == _hw.DeviceType.MLX:
if should_use_mlx_training_backend(device = _hw.DEVICE):
config["resolved_gpu_ids"] = None
config["gpu_selection"] = None
elif gpu_ids:
@ -1022,17 +1463,22 @@ class TrainingBackend:
self._progress.is_training = True
elif etype == "complete":
self._progress.is_training = False
self._progress.is_completed = True
self._output_dir = event.get("output_dir")
msg = event.get("status_message", "Training completed")
stopped = self._should_stop or msg.strip().lower() in {
"training cancelled",
"training stopped",
}
self._progress.is_training = False
self._progress.is_completed = not stopped
self._output_dir = event.get("output_dir")
self._progress.output_dir = self._output_dir
self._progress.status_message = msg
if not self._db_run_created and self.current_job_id and self._db_config:
db_action = "create_and_finalize"
else:
db_action = "finalize"
db_action_kwargs = {
"status": "stopped" if self._should_stop else "completed",
"status": "stopped" if stopped else "completed",
"output_dir": self._output_dir,
}

View file

@ -1309,14 +1309,18 @@ def _normalize_mlx_studio_scheduler(value):
def _resolve_mlx_local_dataset_files(file_paths: list) -> list[str]:
"""Resolve Studio local dataset uploads without importing the GPU trainer."""
"""Resolve CLI paths and Studio local dataset uploads without importing the GPU trainer."""
from utils.paths import resolve_dataset_path
all_files: list[str] = []
for dataset_file in file_paths or []:
file_path = (
dataset_file if os.path.isabs(dataset_file) else str(resolve_dataset_path(dataset_file))
)
dataset_path = Path(os.path.expanduser(str(dataset_file)))
if dataset_path.is_absolute():
file_path = str(dataset_path)
elif dataset_path.exists():
file_path = str(dataset_path.resolve())
else:
file_path = str(resolve_dataset_path(str(dataset_file)))
file_path_obj = Path(file_path)
if file_path_obj.is_dir():
@ -1355,6 +1359,58 @@ def _mlx_local_dataset_loader_for_files(files: list[str]) -> str:
raise ValueError(f"Unsupported dataset format: {files[0]}")
_MLX_WORKER_COMPLETE = "_mlx_worker_complete"
def _start_mlx_stop_poller(stop_queue):
import queue as _queue
import threading
stop_save = [True]
stop_requested = [False]
trainer_ref = [None]
def is_stop_requested():
return stop_requested[0]
def poll_stop():
while True:
try:
msg = stop_queue.get(timeout = 0.25)
if msg and msg.get("type") == _MLX_WORKER_COMPLETE:
return
if msg and msg.get("type") == "stop":
stop_save[0] = msg.get("save", True)
stop_requested[0] = True
trainer = trainer_ref[0]
if trainer is not None:
trainer.stop_requested = True
return
except _queue.Empty:
continue
except (EOFError, OSError):
return
stop_thread = threading.Thread(target = poll_stop, daemon = True)
stop_thread.start()
return stop_save, stop_requested, trainer_ref, is_stop_requested, stop_thread
def _resolve_mlx_output_dir(config, model_name):
from utils.paths import resolve_output_dir, default_run_dir_name
output_dir = config.get("output_dir", "")
if not output_dir:
output_dir = f"{default_run_dir_name(model_name)}_{int(time.time())}"
return str(resolve_output_dir(output_dir))
if config.get("allow_external_output_dir"):
output_path = Path(output_dir).expanduser()
if not output_path.is_absolute():
output_path = Path.cwd() / output_path
return str(output_path.resolve())
return str(resolve_output_dir(output_dir))
def _run_mlx_training(event_queue, stop_queue, config):
"""Self-contained MLX training path for Apple Silicon.
@ -1363,8 +1419,6 @@ def _run_mlx_training(event_queue, stop_queue, config):
"""
import time
import math
import threading
import queue as _queue
from pathlib import Path
def _send(event_type, **kwargs):
@ -1374,31 +1428,9 @@ def _run_mlx_training(event_queue, stop_queue, config):
kwargs["message"] = sm
event_queue.put({"type": event_type, "ts": time.time(), **kwargs})
_stop_save = [True]
_stop_requested = [False]
_trainer_ref = [None]
def _is_stop_requested():
return _stop_requested[0]
def _poll_stop():
while True:
try:
msg = stop_queue.get(timeout = 1.0)
if msg and msg.get("type") == "stop":
_stop_save[0] = msg.get("save", True)
_stop_requested[0] = True
trainer = _trainer_ref[0]
if trainer is not None:
trainer.stop_requested = True
return
except _queue.Empty:
continue
except (EOFError, OSError):
return
stop_thread = threading.Thread(target = _poll_stop, daemon = True)
stop_thread.start()
_stop_save, _stop_requested, _trainer_ref, _is_stop_requested, _stop_thread = (
_start_mlx_stop_poller(stop_queue)
)
_send("status", status_message = "Loading MLX libraries...")
@ -1699,6 +1731,7 @@ def _run_mlx_training(event_queue, stop_queue, config):
# sharegpt+images) and text (alpaca/sharegpt/chatml → "text" column).
format_type = config.get("format_type", "")
custom_format_mapping = config.get("custom_format_mapping")
dataset_final_format = ""
try:
from utils.datasets import format_and_template_dataset
def _fmt_progress(status_message = "", **_kw):
@ -1764,6 +1797,7 @@ def _run_mlx_training(event_queue, stop_queue, config):
)
if info.get("success", True):
dataset = info.get("dataset", dataset)
dataset_final_format = str(info.get("final_format", "") or "").lower()
if eval_dataset is not None:
ev = format_and_template_dataset(
eval_dataset,
@ -1804,21 +1838,14 @@ def _run_mlx_training(event_queue, stop_queue, config):
# ── 5. Build output dir ──
# Resolve to ~/.unsloth/studio/outputs/ so the export page finds it
from utils.paths import resolve_output_dir, ensure_dir
from utils.paths import ensure_dir
output_dir = config.get("output_dir", "")
if not output_dir:
output_dir = build_default_output_dir_name(
model_name,
config.get("project_name"),
)
output_dir = str(resolve_output_dir(output_dir))
output_dir = _resolve_mlx_output_dir(config, model_name)
ensure_dir(Path(output_dir))
# ── 6. Create trainer ──
eval_steps_val = config.get("eval_steps", 0) or 0
if isinstance(eval_steps_val, float) and 0 < eval_steps_val < 1:
# Studio sometimes sends fraction-of-total-steps
eval_steps_val = max(1, int(eval_steps_val * max_steps))
else:
eval_steps_val = int(eval_steps_val)
@ -1869,6 +1896,9 @@ def _run_mlx_training(event_queue, stop_queue, config):
eval_steps = eval_steps_val,
)
# Also gates the masking skip below, so defined outside the feature-detect block.
raw_text_mode = training_type == "Continued Pretraining" or format_type == "raw"
# Feature-detect optional fields so this PR works without the paired zoo bump.
_supported_fields = getattr(MLXTrainingConfig, "__dataclass_fields__", {})
if "cast_norm_output_to_input_dtype" in _supported_fields:
@ -1882,7 +1912,6 @@ def _run_mlx_training(event_queue, stop_queue, config):
if "max_grad_leaf_norm" in _supported_fields:
mlx_config_kwargs["max_grad_leaf_norm"] = max_grad_leaf_norm
if "append_eos" in _supported_fields:
raw_text_mode = training_type == "Continued Pretraining" or format_type == "raw"
# Studio SFT formatting owns rendered examples; raw/CPT text still
# needs MLX to append EOS like the CUDA raw-text path.
mlx_config_kwargs["append_eos"] = bool(raw_text_mode)
@ -1903,29 +1932,27 @@ def _run_mlx_training(event_queue, stop_queue, config):
_send("eval_configured")
# ── 7. Apply train_on_responses_only if requested ──
if config.get("train_on_completions", False):
# Auto-detect markers from the chat template first, manual table as
# fallback. Mirror the CUDA skips: raw/CPT text has no chat turns and
# Alpaca-rendered text lacks the chat markers. Also check the resolved
# format, since format_type="auto" can land on alpaca or raw text.
if (
config.get("train_on_completions", False)
and not raw_text_mode
and format_type != "alpaca"
and dataset_final_format not in ("alpaca", "raw_text")
):
_send("status", status_message = "Configuring response-only training...")
try:
from utils.datasets import (
MODEL_TO_TEMPLATE_MAPPER,
TEMPLATE_TO_RESPONSES_MAPPER,
)
template_name = MODEL_TO_TEMPLATE_MAPPER.get(model_name.lower())
markers = TEMPLATE_TO_RESPONSES_MAPPER.get(template_name) if template_name else None
if markers:
trainer = train_on_responses_only(
trainer,
instruction_part = markers["instruction"],
response_part = markers["response"],
)
else:
_send(
"status",
status_message = f"train_on_completions skipped (no template for {model_name})",
)
except Exception as e:
_send("status", status_message = f"train_on_completions failed: {e}")
# No catch: the helper handles detection failures and double misses, so
# an exception here is a real masking failure that must fail the run,
# not silently train on full sequences.
from utils.datasets.completion_masking import apply_completion_masking
trainer, _masking_applied = apply_completion_masking(
trainer,
model_name,
train_on_responses_only,
notify = lambda level, message: _send("status", status_message = message),
)
# ── 8. Setup wandb / tensorboard ──
wandb_run = None
@ -2043,12 +2070,27 @@ def _run_mlx_training(event_queue, stop_queue, config):
# ── 11. Run training ──
gc.collect()
mx.synchronize()
trainer.train(resume_from_checkpoint = resume_from_checkpoint)
_save_model = trainer.save_model
def _skip_internal_final_save(*args, **kwargs):
raise ValueError("worker owns final save")
trainer.save_model = _skip_internal_final_save
try:
trainer.train(resume_from_checkpoint = resume_from_checkpoint)
finally:
trainer.save_model = _save_model
# ── 12. Save and finalize ──
if trainer.stop_requested and not _stop_save[0]:
# User clicked "Cancel" (save=False) — skip saving
_send("complete", output_dir = None, status_message = "Training cancelled")
if trainer.stop_requested:
if not _stop_save[0]:
# Cancel (save=False): skip saving.
_send("complete", output_dir = None, status_message = "Training cancelled")
else:
_send("status", status_message = "Saving stopped model...")
mx.synchronize()
trainer.save_model(output_dir)
_send("complete", output_dir = output_dir, status_message = "Training stopped")
else:
_send("status", status_message = "Saving model...")
mx.synchronize()
@ -2067,6 +2109,79 @@ def _run_mlx_training(event_queue, stop_queue, config):
pass
def _is_current_process_apple_silicon() -> bool:
import platform
return platform.system() == "Darwin" and platform.machine() == "arm64"
def run_mlx_training_process(
*,
event_queue: Any,
stop_queue: Any,
config: dict,
transformers_activated: bool = False,
) -> None:
"""MLX worker entrypoint shared by Studio subprocesses and the CLI adapter."""
model_name = config["model_name"]
backend_path = str(Path(__file__).resolve().parent.parent.parent)
if backend_path not in sys.path:
sys.path.insert(0, backend_path)
from utils.hf_xet_fallback import child_should_disable_xet
if child_should_disable_xet(config):
os.environ["HF_HUB_DISABLE_XET"] = "1"
os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "0"
if not transformers_activated:
# Must precede detect_hardware(): its MLX stack check imports mlx_lm, hence transformers.
_activate_transformers_version_or_warn(model_name, config.get("hf_token") or None)
from utils.hardware import hardware as _hw
_hw.detect_hardware()
if _hw.DEVICE != _hw.DeviceType.MLX:
event_queue.put(
{
"type": "error",
"error": "MLX training requires Apple Silicon with the MLX backend available.",
"stack": "",
"ts": time.time(),
}
)
return
if config.get("is_dataset_audio"):
event_queue.put(
{
"type": "error",
"error": "Audio dataset training is not yet supported on Apple Silicon.",
"stack": "",
"ts": time.time(),
}
)
return
try:
try:
_run_mlx_training(event_queue, stop_queue, config)
finally:
try:
stop_queue.put({"type": _MLX_WORKER_COMPLETE})
except (EOFError, OSError, ValueError):
pass
except Exception as exc:
event_queue.put(
{
"type": "error",
"error": str(exc),
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
}
)
def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) -> None:
"""Subprocess entrypoint. Fresh Python — no stale module state.
@ -2141,36 +2256,26 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
if backend_path not in sys.path:
sys.path.insert(0, backend_path)
from .training import is_apple_silicon_training_platform, should_use_mlx_training_backend
mlx_backend_requested = is_apple_silicon_training_platform()
mlx_transformers_activated = False
if mlx_backend_requested and _is_current_process_apple_silicon():
# Must precede detect_hardware(): its MLX stack check imports mlx_lm, hence transformers.
_activate_transformers_version_or_warn(model_name, config.get("hf_token") or None)
mlx_transformers_activated = True
from utils.hardware import hardware as _hw
_hw.detect_hardware()
if _hw.DEVICE == _hw.DeviceType.MLX:
if config.get("is_dataset_audio"):
event_queue.put(
{
"type": "error",
"error": "Audio dataset training is not yet supported on Apple Silicon.",
"stack": "",
"ts": time.time(),
}
)
return
# Activate correct transformers version (Gemma-4 needs a 5.x sidecar, etc.)
# Must happen before any transformers/mlx-lm imports in _run_mlx_training.
# Non-fatal: fall through with whatever version is installed, but log
# the failure instead of swallowing it (issue #6103).
_activate_transformers_version_or_warn(model_name, config.get("hf_token") or None)
try:
_run_mlx_training(event_queue, stop_queue, config)
except Exception as exc:
event_queue.put(
{
"type": "error",
"error": str(exc),
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
}
)
if mlx_backend_requested or should_use_mlx_training_backend(device = _hw.DEVICE):
run_mlx_training_process(
event_queue = event_queue,
stop_queue = stop_queue,
config = config,
transformers_activated = mlx_transformers_activated,
)
return
# ── 1. Activate correct transformers version BEFORE any ML imports ──
@ -2693,7 +2798,8 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
if backend_path not in sys.path:
sys.path.insert(0, backend_path)
from core.training.trainer import UnslothTrainer, TrainingProgress
from core.training.training import TrainingProgress
from core.training.trainer import UnslothTrainer
from utils.paths import (
ensure_dir,
resolve_output_dir,

View file

@ -105,6 +105,10 @@ def test_repo_id_validation_accepts_hf_repo_id_contract(repo_id):
assert paths.is_valid_repo_id(repo_id)
def test_repo_id_validation_accepts_max_length_namespaced_repo():
assert paths.is_valid_repo_id(f"{'a' * 96}/{'b' * 96}")
@pytest.mark.parametrize(
"repo_id",
[
@ -121,6 +125,48 @@ def test_repo_id_validation_rejects_unsafe_or_invalid_ids(repo_id):
assert not paths.is_valid_repo_id(repo_id)
def test_download_state_preserves_readable_keys_when_safe(monkeypatch, tmp_path):
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path)
path = state_dir.marker_path("model", "Owner/Repo", "Q4_K_M")
assert path is not None
assert path.name == "models--owner--repo--variant--q4_k_m.json"
@pytest.mark.parametrize("variant", ["bad variant with spaces", "q" * 64])
def test_download_state_bounds_long_repo_variant_filenames(monkeypatch, tmp_path, variant):
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path)
repo_id = f"{'a' * 96}/{'b' * 96}"
assert paths.is_valid_repo_id(repo_id)
assert download_manifest.write_cancel_marker("model", repo_id, variant, "http")
assert download_manifest.write_manifest(
"model",
repo_id,
variant,
[download_manifest.ExpectedFile(path = "model.gguf", size = 1)],
"http",
)
marker_path = state_dir.marker_path("model", repo_id, variant)
manifest_path = state_dir.manifest_path("model", repo_id, variant)
assert marker_path is not None
assert manifest_path is not None
assert "--sha256-" in marker_path.name
assert len(marker_path.name.encode("utf-8")) <= 255
assert len(f".{marker_path.name}.tmp-00000000".encode("utf-8")) <= 255
assert download_manifest.has_cancel_marker("model", repo_id, variant)
assert download_manifest.read_manifest("model", repo_id, variant) is not None
assert list(download_manifest.iter_variant_markers("model", repo_id)) == [
(variant, marker_path)
]
assert list(download_manifest.iter_variant_manifests("model", repo_id)) == [
(variant, manifest_path)
]
class _RecordingLogger:
def __init__(self):
self.warnings = []

View file

@ -102,16 +102,17 @@ def preferred_mmproj_sibling(siblings: Sequence) -> Optional[object]:
def preferred_mtp_sibling(siblings: Sequence) -> Optional[object]:
"""The separate MTP drafter to fetch with every variant: the repo-root
``mtp-*.gguf`` copy unsloth ships for llama.cpp ``-hf`` auto-discovery
(Gemma 4). Same pick as the loader's drafter resolution (``mtp-`` basename
prefix, first in sort order) so download and load resolve the same file;
the higher-precision ``MTP/`` subdir copies are for explicit selection and
are not auto-fetched. None for repos with the head baked into the main
GGUF (Qwen)."""
(Gemma 4). Same pick as the loader's drafter resolution (root-level
``mtp-`` prefix, first in sort order) so download and load resolve the same
file; the higher-precision ``MTP/`` subdir copies are for explicit
selection and are not auto-fetched. None for repos with the head baked into
the main GGUF (Qwen)."""
# Root-level only: the MTP/ subdir copies now share the mtp- prefix too.
candidates = sorted(
(
s
for s in siblings
if (name := _gguf_rfilename(s)) and name.lower().rsplit("/", 1)[-1].startswith("mtp-")
if (name := _gguf_rfilename(s)) and "/" not in name and name.lower().startswith("mtp-")
),
key = lambda s: getattr(s, "rfilename"),
)

View file

@ -181,15 +181,20 @@ def is_valid_repo_id(repo_id: str) -> bool:
"""Validate Hugging Face ``repo_name`` or ``namespace/repo_name`` IDs."""
if not repo_id or repo_id != repo_id.strip():
return False
if len(repo_id) > _MAX_REPO_ID_LENGTH or repo_id.endswith(".git"):
if repo_id.endswith(".git"):
return False
if "--" in repo_id or ".." in repo_id:
return False
segments = repo_id.split("/")
if len(segments) not in (1, 2):
return False
# Match huggingface_hub.validate_repo_id: the 96-char limit applies per
# segment (repo name / namespace), not to the whole "namespace/repo_name"
# string, so long-but-valid repo names are not falsely rejected.
return all(
segment not in ("", ".", "..") and _VALID_REPO_ID_SEGMENT.fullmatch(segment) is not None
segment not in ("", ".", "..")
and len(segment) <= _MAX_REPO_ID_LENGTH
and _VALID_REPO_ID_SEGMENT.fullmatch(segment) is not None
for segment in segments
)

View file

@ -11,8 +11,9 @@ cache lifecycle. Two subdirectories:
manifests/ <key>.json per-download expected-files manifest
cancelled/ <key>.json per-download cancel marker
The ``<key>`` mirrors HF's cache dir naming so a state file can be
eyeballed next to the on-disk repo it describes:
The ``<key>`` mirrors HF's cache dir naming while the resulting manifest,
cancel-marker, and atomic-write temp filenames fit common filesystem basename
limits. Very long repo IDs use a stable hash in the state key:
models--<owner>--<name> full snapshot
models--<owner>--<name>--variant--<variant> GGUF variant
@ -49,6 +50,11 @@ _MANIFESTS_SUBDIR = "manifests"
_CANCELLED_SUBDIR = "cancelled"
_WORKERS_SUBDIR = "workers"
_SAFE_VARIANT_FRAGMENT = re.compile(r"^[a-z0-9._-]{1,64}$")
_MAX_STATE_BASENAME_BYTES = 255
_STATE_EXTENSION = ".json"
# _atomic_write_json writes ".<target>.tmp-<8hex>" beside the final file.
_ATOMIC_WRITE_TMP_OVERHEAD = len(".") + len(".tmp-") + 8
_MAX_VARIANT_FRAGMENT_LENGTH = 64
def state_root() -> Optional[Path]:
@ -84,16 +90,35 @@ def repo_cache_basename(repo_type: RepoType, repo_id: str) -> str:
return f"{repo_type}s--{repo_id.replace('/', '--')}".lower()
def _filename_bytes(name: str) -> int:
return len(name.encode("utf-8"))
def _state_filename_fits(entry_key: str) -> bool:
filename = f"{entry_key}{_STATE_EXTENSION}"
return _filename_bytes(filename) + _ATOMIC_WRITE_TMP_OVERHEAD <= _MAX_STATE_BASENAME_BYTES
def _state_repo_key(repo_type: RepoType, repo_id: str) -> str:
base = repo_cache_basename(repo_type, repo_id)
variant_prefix = f"{base}--variant--"
longest_variant_key = f"{variant_prefix}{'x' * _MAX_VARIANT_FRAGMENT_LENGTH}"
if _state_filename_fits(longest_variant_key):
return base
digest = hashlib.sha256(base.encode("utf-8")).hexdigest()[:32]
return f"{repo_type}s--sha256-{digest}"
def variant_filename_prefix(repo_type: RepoType, repo_id: str) -> str:
"""Lowercased prefix every variant-keyed state file for this repo shares.
The single source the download_manifest enumerators match against, so the
scheme in :func:`_entry_key` cannot drift from them silently."""
return f"{repo_cache_basename(repo_type, repo_id)}--variant--"
return f"{_state_repo_key(repo_type, repo_id)}--variant--"
def _entry_key(repo_type: RepoType, repo_id: str, variant: Optional[str]) -> str:
base = repo_cache_basename(repo_type, repo_id)
base = _state_repo_key(repo_type, repo_id)
if not variant:
return base
normalized_variant = variant.strip().lower()

View file

@ -177,6 +177,7 @@ class GenerateRequest(BaseModel):
temperature: float = Field(0.6, ge = 0.0, le = 2.0, description = "Sampling temperature")
top_p: float = Field(0.95, ge = 0.0, le = 1.0, description = "Top-p sampling")
top_k: int = Field(20, ge = -1, le = 100, description = "Top-k sampling")
min_p: float = Field(0.0, ge = 0.0, le = 1.0, description = "Min-p sampling")
max_new_tokens: int = Field(2048, ge = 1, le = 4096, description = "Maximum tokens to generate")
repetition_penalty: float = Field(1.0, ge = 1.0, le = 2.0, description = "Repetition penalty")
presence_penalty: float = Field(0.0, ge = 0.0, le = 2.0, description = "Presence penalty")
@ -1146,7 +1147,8 @@ class CompletionMessage(BaseModel):
"""The assistant's complete response message."""
role: Literal["assistant"] = "assistant"
content: str
# ``None`` on a pure tool-call turn (OpenAI content=null); string otherwise.
content: Optional[str] = None
refusal: Optional[str] = None
reasoning_content: Optional[str] = None
tool_calls: Optional[list[dict]] = None
@ -1531,12 +1533,41 @@ class AnthropicToolResultBlock(BaseModel):
tool_use_id: str
content: Union[str, list] = ""
@field_validator("content", mode = "before")
@classmethod
def _coerce_null_content(cls, v):
# Some clients send null content for an empty tool result; the str|list
# union would 400 on it, so treat null as "".
return "" if v is None else v
# Block types the converter translates explicitly. Anything else (thinking /
# redacted_thinking, a provider block a resumed session replays, or a future type)
# is accepted as an unknown block and dropped by the converter, rather than 400-ing
# the whole request on strict validation.
_KNOWN_ANTHROPIC_BLOCK_TYPES = frozenset({"text", "image", "tool_use", "tool_result"})
class AnthropicUnknownBlock(BaseModel):
type: str
model_config = {"extra": "allow"}
@field_validator("type")
@classmethod
def _only_unknown_types(cls, v):
# Known types parse as their typed models above (so a malformed known block
# still fails cleanly); this fallback only catches the rest.
if v in _KNOWN_ANTHROPIC_BLOCK_TYPES:
raise ValueError("known block type handled by its typed model")
return v
AnthropicContentBlock = Union[
AnthropicTextBlock,
AnthropicImageBlock,
AnthropicToolUseBlock,
AnthropicToolResultBlock,
AnthropicUnknownBlock,
]
@ -1581,6 +1612,40 @@ class AnthropicMessage(BaseModel):
role: Literal["user", "assistant"]
content: Union[str, list[AnthropicContentBlock]]
@model_validator(mode = "before")
@classmethod
def _normalize_content(cls, data):
# Role-aware leniency that never silently drops real user input:
# - assistant: a resumed tool-only turn's null content -> "" (str|list would
# 400 on null; "" keeps the converter's `for block in content` safe).
# Unknown blocks (thinking / future types) validate via
# AnthropicUnknownBlock and are dropped by the converter.
# - user: keep strict. Null user content stays None so str|list rejects it
# (400) rather than forwarding an empty prompt; and reject block types the
# converter cannot translate, since it silently skips unknown user blocks
# -- a user turn made only of them would validate yet send no content
# (silent data loss).
if not isinstance(data, dict):
return data
content = data.get("content")
if data.get("role") == "assistant":
# Coerce only an explicit null (resumed tool-only turn). A missing
# content key stays malformed so the required-field check still 400s.
if "content" in data and content is None:
return {**data, "content": ""}
return data
if isinstance(content, list):
for block in content:
btype = (
block.get("type") if isinstance(block, dict) else getattr(block, "type", None)
)
# Guard the value: a non-string type is unsupported too, and a
# membership test on an unhashable value would raise TypeError
# (escaping as a 500 instead of a clean 400).
if not isinstance(btype, str) or btype not in _KNOWN_ANTHROPIC_BLOCK_TYPES:
raise ValueError(f"unsupported content block type {btype!r} in a user message")
return data
class AnthropicTool(BaseModel):
# Client tools have input_schema; server tools may only have type/name.

View file

@ -10,3 +10,9 @@ transformers>=4.57.6
# 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
# mlx-lm 0.31.3 regressed QK-norm archs (gemma4 / qwen3_5): strict load_weights
# rejects q_norm/k_norm, so those checkpoints fail to load. mlx-lm #1242.
# The override also drops it from transitive resolution; keep the >=0.22.0 floor
# (mirrors mlx_repair.py _MLX_MIN_VERSIONS) or the resolver could go below it.
mlx-lm>=0.22.0,!=0.31.3

View file

@ -56,6 +56,7 @@ class ChatThread(BaseModel):
projectId: Optional[str] = None
archived: bool = False
createdAt: int
updatedAt: Optional[int] = None
openaiCodeExecContainerId: Optional[str] = None
anthropicCodeExecContainerId: Optional[str] = None
forkedFromThreadId: Optional[str] = None
@ -70,6 +71,7 @@ class ChatThreadPatch(BaseModel):
projectId: Optional[str] = None
archived: Optional[bool] = None
createdAt: Optional[int] = None
updatedAt: Optional[int] = None
openaiCodeExecContainerId: Optional[str] = None
anthropicCodeExecContainerId: Optional[str] = None
@ -177,6 +179,7 @@ class ChatSettingsPayload(BaseModel):
collapseHtmlArtifacts: Optional[bool] = None
allowArtifactNetworkAccess: Optional[bool] = None
autoHealToolCalls: Optional[bool] = None
nudgeToolCalls: Optional[bool] = None
maxToolCallsPerMessage: Optional[int] = Field(default = None, ge = 1)
toolCallTimeout: Optional[int] = Field(default = None, ge = 1)
@ -251,7 +254,7 @@ async def patch_thread(
current_subject: str = Depends(get_current_subject),
):
patch = payload.model_dump(exclude_unset = True)
for field in ("title", "modelType", "modelId", "archived", "createdAt"):
for field in ("title", "modelType", "modelId", "archived", "createdAt", "updatedAt"):
if field in patch and patch[field] is None:
raise HTTPException(status_code = 400, detail = f"{field} cannot be null")
if patch.get("projectId") and get_chat_project(patch["projectId"]) is None:

View file

@ -10,6 +10,7 @@ import binascii
import json
import os
import re
import shutil
from itertools import islice
from pathlib import Path
from typing import Any
@ -59,6 +60,9 @@ UNSTRUCTURED_ALLOWED_EXTS = {".pdf", ".docx", ".txt", ".md"}
SEED_UPLOAD_DIR = seed_uploads_root()
UNSTRUCTURED_UPLOAD_ROOT = unstructured_uploads_root()
_SAFE_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
# Frontend-generated upload namespace (UUID4 hex). Legacy node ids (n1, ...)
# never match: those directories can be shared by several recipes.
_UPLOAD_UID_RE = re.compile(r"^[0-9a-f]{32}$")
def _validate_safe_id(value: str, label: str) -> str:
@ -580,6 +584,39 @@ async def remove_unstructured_file(block_id: str, file_id: str):
return {"status": "ok"}
@router.delete("/seed/unstructured-block/{block_id}")
async def remove_unstructured_block(block_id: str):
"""Delete a block's upload directory; files on disk still count toward its quota.
Only uid-namespaced directories may be bulk-deleted: they have exactly one
owning block. Legacy node-id directories (n1, ...) can be shared by other
recipes, so they are managed file-by-file instead.
"""
_validate_safe_id(block_id, "block_id")
if not _UPLOAD_UID_RE.match(block_id):
raise HTTPException(400, "Invalid block_id: only uid-namespaced blocks can be deleted")
block_dir = (UNSTRUCTURED_UPLOAD_ROOT / block_id).resolve()
if not block_dir.is_relative_to(UNSTRUCTURED_UPLOAD_ROOT.resolve()):
raise HTTPException(400, "Invalid block_id: outside upload root")
if not block_dir.exists():
return {"status": "ok", "deleted": False}
try:
shutil.rmtree(block_dir)
except OSError as exc:
raise log_and_http_error(
exc,
500,
"failed to delete uploaded files",
event = "data_recipe.seed.unstructured_block_delete_failed",
log = logger,
) from exc
if block_dir.exists():
raise HTTPException(500, "failed to delete uploaded files")
return {"status": "ok", "deleted": True}
@router.post("/seed/inspect-upload", response_model = SeedInspectResponse)
def inspect_seed_upload(payload: SeedInspectUploadRequest) -> SeedInspectResponse:
if payload.file_ids is not None:

File diff suppressed because it is too large Load diff

View file

@ -32,6 +32,7 @@ from utils.helper_precache_settings import (
helper_model_disabled_by_env,
set_helper_precache_enabled,
)
from utils.coding_agents import CODING_AGENTS, detect_installed_coding_agents
from utils.openai_auto_switch_settings import (
DEFAULT_AUTO_UNLOAD_IDLE_SECONDS,
DEFAULT_OPENAI_AUTO_SWITCH_ENABLED,
@ -174,6 +175,19 @@ def update_helper_precache(
return _helper_precache_response(enabled)
class CodingAgentsResponse(BaseModel):
# All agents `unsloth start` supports, in the CLI's declared order.
agents: tuple[str, ...] = CODING_AGENTS
# Subset of `agents` whose CLI binary was found on PATH; the frontend uses
# this to default the API-keys panel to a command the user can run as-is.
detected: list[str]
@router.get("/coding-agents", response_model = CodingAgentsResponse)
def get_coding_agents(current_subject: str = Depends(get_current_subject)) -> CodingAgentsResponse:
return CodingAgentsResponse(detected = detect_installed_coding_agents())
@router.get("/openai-auto-switch", response_model = OpenAIAutoSwitchResponse)
def get_openai_auto_switch(
current_subject: str = Depends(get_current_subject),
@ -260,17 +274,29 @@ def _embedding_model_response() -> EmbeddingModelResponse:
)
def _llama_backend_active() -> bool:
"""True when this install embeds via the llama-server (GGUF) backend."""
from core.rag import config as rag_config
from core.rag import embeddings
def _ambient_hf_token() -> Optional[str]:
"""The HF token the loader would use (HF_TOKEN env or the cached login), so a gated
repo is scanned rather than failing open. None if unavailable."""
try:
raw = (rag_config.EMBED_BACKEND or "auto").strip().lower()
key = embeddings._resolve_auto() if raw in embeddings._AUTO_ALIASES else raw
from huggingface_hub import get_token
return get_token()
except Exception:
return None
def _llama_backend_active() -> bool:
"""True when this install actually embeds via the llama-server (GGUF) backend.
Delegates to the embeddings module so a runtime fallback from
sentence-transformers to llama-server (after a torch/CUDA load or encode
failure) is honored: in that state the process loads only inert GGUF, so the
ST pickle gate below must not hard-block a repo whose GGUF companion is clean.
Before any backend is built this still reflects the resolver."""
from core.rag import embeddings
try:
return embeddings.active_backend_is_llama()
except Exception: # noqa: BLE001 - backend probe must never block saving
return False
return key in embeddings._LLAMA_ALIASES
def _resolves_as_local_gguf(model: str) -> bool:
@ -357,6 +383,8 @@ def update_embedding_model(
"""Set the RAG embedding model. Unless ``force`` is set, the repo is verified
to be an embedding model via HF metadata; an unverifiable model (wrong type,
typo, gated repo, or no network) returns 409 so the UI can offer "save anyway".
A repo flagged unsafe by HF's security scan returns 403 instead: a hard block
that ``force`` cannot bypass, so the UI must not offer "save anyway".
Documents indexed under the previous model must be re-uploaded."""
from utils.models import is_embedding_model
@ -370,15 +398,51 @@ def update_embedding_model(
event = "settings.update_embedding_model_failed",
log = logger,
) from exc
hf_token = (payload.hf_token or "").strip() or None
# The env/default model needs no verification; saving it is a no-op override.
# A local GGUF on the llama-server backend is accepted as-is: it is exactly
# what the backend loads, and HF metadata cannot verify a local path.
if (
model != default_embedding_model()
and not payload.force
and not (_llama_backend_active() and _resolves_as_local_gguf(model))
):
hf_token = (payload.hf_token or "").strip() or None
is_local_gguf = _llama_backend_active() and _resolves_as_local_gguf(model)
# The pickle gate only matters for the sentence-transformers backend, which is what
# deserializes pickles. On the llama-server backend the embedder loads GGUF files
# (inert) from effective_gguf_repo(), so scanning the ST repo's pickle here would
# wrongly reject a custom repo whose GGUF companion is clean; the GGUF availability
# checks below cover that path instead.
scan_st_pickle = (
model != default_embedding_model() and not is_local_gguf and not _llama_backend_active()
)
if scan_st_pickle:
# Malware/pickle gate before we persist a repo the embedder later loads with
# SentenceTransformer. Runs even under force (force only skips the is-embedding
# type check for offline/local repos HF cannot verify); local paths and
# unreachable scans fail open inside evaluate_file_security.
from utils.security import evaluate_file_security, security_load_subdirs
from core.rag.embeddings import _st_module_subdirs
# Fall back to the loader's own token so a gated/private repo is actually scanned
# (a token-less scan fails open for exactly the repo that would still load).
scan_token = hf_token or _ambient_hf_token()
# Include the ST module dirs (0_Transformer/) so a flagged pickle directly under
# one blocks instead of passing as an unreferenced nested shard.
load_subdirs = tuple(
dict.fromkeys(
(
*security_load_subdirs(model, scan_token),
*_st_module_subdirs(model, scan_token),
)
)
)
if evaluate_file_security(model, hf_token = scan_token, load_subdirs = load_subdirs).blocked:
# 403, not 409: the client routes every 409 into the forceable "save anyway"
# flow, but this block is a hard, non-forceable security refusal.
raise HTTPException(
status_code = 403,
detail = (
f"{model!r} is flagged as unsafe by Hugging Face's security scan and "
"cannot be used as the embedding model."
),
)
if model != default_embedding_model() and not payload.force and not is_local_gguf:
from core.rag import config as rag_config
# A GGUF-named repo on the llama-server backend is loaded from its .gguf

View file

@ -12,6 +12,79 @@ import time
from pathlib import Path
from typing import Optional
def _fix_torch_cuda_ld_path():
"""Prepend torch's bundled CUDA libs to LD_LIBRARY_PATH.
PyTorch wheels ship their own CUDA runtime (libcudart, libcublas, ...) in
``site-packages/nvidia/*/lib``. On Linux the dynamic linker reads
LD_LIBRARY_PATH before the RUNPATH baked into torch's .so files, so a
pre-existing LD_LIBRARY_PATH pointing at a different system CUDA (e.g.
/usr/local/cuda-13/lib64 from conda or a Docker base image) shadows torch's
libs and triggers "undefined symbol" errors when torch is imported. Detect
torch's lib dirs (without importing torch) and prepend them. Returns True if
LD_LIBRARY_PATH was changed.
"""
if sys.platform != "linux":
return False
ld_path = os.environ.get("LD_LIBRARY_PATH", "")
if not ld_path:
return False
try:
import importlib.util
spec = importlib.util.find_spec("torch")
if not spec or not spec.origin:
return False
torch_dir = os.path.dirname(spec.origin)
site_pkgs = os.path.dirname(torch_dir)
nvidia_dir = os.path.join(site_pkgs, "nvidia")
lib_dirs = []
torch_lib = os.path.join(torch_dir, "lib")
if os.path.isdir(torch_lib):
lib_dirs.append(torch_lib)
if os.path.isdir(nvidia_dir):
for sub in sorted(os.listdir(nvidia_dir)):
lib = os.path.join(nvidia_dir, sub, "lib")
if os.path.isdir(lib):
lib_dirs.append(lib)
if not lib_dirs:
return False
existing = ld_path.split(":")
if existing[: len(lib_dirs)] == lib_dirs:
return False # already at the front, nothing to do
torch_set = set(lib_dirs)
cleaned = [p for p in existing if p not in torch_set]
os.environ["LD_LIBRARY_PATH"] = ":".join(lib_dirs + cleaned)
return True
except Exception:
return False
_LD_FIXED_SENTINEL = "_UNSLOTH_STUDIO_LD_FIXED"
def _maybe_reexec_for_cuda_ld_path():
"""Re-exec once so the dynamic linker sees the corrected LD_LIBRARY_PATH.
LD_LIBRARY_PATH is read at process start, so editing os.environ in-process
cannot fix the running interpreter; a single re-exec is required. Call only
from a true entry point (the ``if __name__ == "__main__"`` block), never at
import time, because os.execv replaces the whole process (an embedder such
as Colab that does ``from run import run_server`` must not be re-exec'd).
"""
if _LD_FIXED_SENTINEL in os.environ:
return
if not _fix_torch_cuda_ld_path():
return
os.environ[_LD_FIXED_SENTINEL] = "1"
argv = getattr(sys, "orig_argv", None) or [sys.executable, *sys.argv]
os.execv(sys.executable, argv)
# Suppress C-level dependency warnings globally (e.g. SwigPyPacked).
os.environ["PYTHONWARNINGS"] = "ignore"
@ -1457,6 +1530,12 @@ def _build_arg_parser():
# For direct execution (also invoked by CLI via os.execvp / subprocess).
if __name__ == "__main__":
# Correct a conflicting system CUDA on LD_LIBRARY_PATH before torch is
# imported (below, via run_server). Re-execs once on Linux so the dynamic
# linker uses torch's bundled CUDA libs; no-op on other platforms, when
# LD_LIBRARY_PATH is unset or already correct, or after the single re-exec.
_maybe_reexec_for_cuda_ld_path()
import signal
import traceback

View file

@ -240,6 +240,7 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
project_id TEXT,
archived INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER,
openai_code_exec_container_id TEXT,
anthropic_code_exec_container_id TEXT,
forked_from_thread_id TEXT,
@ -261,6 +262,24 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
conn.execute("ALTER TABLE chat_threads ADD COLUMN forked_from_thread_id TEXT")
if "forked_from_message_id" not in chat_thread_cols:
conn.execute("ALTER TABLE chat_threads ADD COLUMN forked_from_message_id TEXT")
if "updated_at" not in chat_thread_cols:
conn.execute("ALTER TABLE chat_threads ADD COLUMN updated_at INTEGER")
# Floor at created_at: forked threads copy older ancestor messages,
# so the fork's creation time must win over the branch message times.
conn.execute(
"""
UPDATE chat_threads SET updated_at = MAX(
COALESCE(
(
SELECT MAX(m.created_at) FROM chat_messages m
WHERE m.thread_id = chat_threads.id
),
created_at
),
created_at
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_messages (
@ -992,6 +1011,9 @@ def _chat_thread_from_row(row: sqlite3.Row) -> dict:
"projectId": data.get("project_id") or None,
"archived": bool(data["archived"]),
"createdAt": data["created_at"],
"updatedAt": data.get("updated_at")
if data.get("updated_at") is not None
else data["created_at"],
"openaiCodeExecContainerId": data.get("openai_code_exec_container_id"),
"anthropicCodeExecContainerId": data.get("anthropic_code_exec_container_id"),
"forkedFromThreadId": data.get("forked_from_thread_id"),
@ -1039,8 +1061,8 @@ def upsert_chat_thread(thread: dict) -> dict:
conn.execute(
"""
INSERT INTO chat_threads
(id, title, model_type, model_id, pair_id, project_id, archived, created_at, openai_code_exec_container_id, anthropic_code_exec_container_id, forked_from_thread_id, forked_from_message_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(id, title, model_type, model_id, pair_id, project_id, archived, created_at, updated_at, openai_code_exec_container_id, anthropic_code_exec_container_id, forked_from_thread_id, forked_from_message_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
model_type = excluded.model_type,
@ -1049,6 +1071,7 @@ def upsert_chat_thread(thread: dict) -> dict:
project_id = excluded.project_id,
archived = excluded.archived,
created_at = excluded.created_at,
updated_at = COALESCE(excluded.updated_at, chat_threads.updated_at),
openai_code_exec_container_id = excluded.openai_code_exec_container_id,
anthropic_code_exec_container_id = excluded.anthropic_code_exec_container_id,
forked_from_thread_id = excluded.forked_from_thread_id,
@ -1063,6 +1086,7 @@ def upsert_chat_thread(thread: dict) -> dict:
thread.get("projectId"),
1 if thread.get("archived") else 0,
int(thread["createdAt"]),
int(thread["updatedAt"]) if thread.get("updatedAt") is not None else None,
thread.get("openaiCodeExecContainerId"),
thread.get("anthropicCodeExecContainerId"),
thread.get("forkedFromThreadId"),
@ -1084,6 +1108,7 @@ def update_chat_thread(id: str, patch: dict) -> Optional[dict]:
"projectId": ("project_id", patch.get("projectId")),
"archived": ("archived", 1 if patch.get("archived") else 0),
"createdAt": ("created_at", patch.get("createdAt")),
"updatedAt": ("updated_at", patch.get("updatedAt")),
"openaiCodeExecContainerId": (
"openai_code_exec_container_id",
patch.get("openaiCodeExecContainerId"),
@ -1155,7 +1180,8 @@ def list_chat_threads(
conn = get_connection()
try:
rows = conn.execute(
f"SELECT * FROM chat_threads {where} ORDER BY created_at DESC",
f"SELECT * FROM chat_threads {where} "
"ORDER BY COALESCE(updated_at, created_at) DESC, created_at DESC",
values,
).fetchall()
return [_chat_thread_from_row(row) for row in rows]
@ -1394,6 +1420,44 @@ def _raise_if_chat_message_thread_conflicts(
)
def _bump_chat_thread_updated_at(
conn: sqlite3.Connection, thread_id: str, message_created_at: int
) -> None:
conn.execute(
"""
UPDATE chat_threads
SET updated_at = MAX(COALESCE(updated_at, created_at), ?)
WHERE id = ?
""",
(message_created_at, thread_id),
)
def _recompute_chat_thread_updated_at(conn: sqlite3.Connection, thread_id: str) -> None:
"""Set updated_at from the remaining messages, floored at created_at.
Unlike the ratchet-only bump, this can lower updated_at -- needed after
pruning, which may delete the thread's newest message.
"""
conn.execute(
"""
UPDATE chat_threads
SET updated_at = MAX(
COALESCE(
(
SELECT MAX(m.created_at) FROM chat_messages m
WHERE m.thread_id = chat_threads.id
),
created_at
),
created_at
)
WHERE id = ?
""",
(thread_id,),
)
def upsert_chat_message(message: dict) -> dict:
conn = get_connection()
try:
@ -1432,6 +1496,7 @@ def upsert_chat_message(message: dict) -> dict:
int(message["createdAt"]),
),
)
_bump_chat_thread_updated_at(conn, message["threadId"], int(message["createdAt"]))
conn.commit()
return message
except Exception:
@ -1484,6 +1549,12 @@ def sync_chat_messages(
for m in messages
],
)
if prune_missing:
_recompute_chat_thread_updated_at(conn, thread_id)
elif messages:
_bump_chat_thread_updated_at(
conn, thread_id, max(int(m["createdAt"]) for m in messages)
)
conn.commit()
return list_chat_messages(thread_id)
except ChatMessageConflictError:

View file

@ -52,6 +52,49 @@ from io import BytesIO as _BytesIO
from types import SimpleNamespace
def _emitter_client_text(events: list[str]) -> str:
"""Concatenate the text_delta payloads an SSE event list carries."""
text = ""
for line in events:
for raw in line.split("\n"):
raw = raw.strip()
if not raw.startswith("data: "):
continue
data = json.loads(raw[len("data: ") :])
delta = data.get("delta", {})
if delta.get("type") == "text_delta":
text += delta.get("text", "")
return text
def test_anthropic_emitter_closes_reasoning_only_think_block():
# A reasoning-only reply streams <think>X live then shrinks to bare X at EOF.
# This emitter diffs cumulative snapshots and drops the shrink, so without a
# closing pass the client text would end on an unclosed <think>. finish()
# must balance it.
emitter = AnthropicStreamEmitter()
events = emitter.start("msg_1", "m")
events += emitter.feed({"type": "content", "text": "<think>The capital"})
events += emitter.feed({"type": "content", "text": "<think>The capital of France is Paris."})
# The generator's final bare-text shrink (dropped by the cumulative diff).
events += emitter.feed({"type": "content", "text": "The capital of France is Paris."})
events += emitter.finish()
assert _emitter_client_text(events) == "<think>The capital of France is Paris.</think>"
def test_anthropic_emitter_does_not_double_close_balanced_think():
# A reasoning-then-answer reply already closes its own </think>; the balancer
# must not append a second one.
emitter = AnthropicStreamEmitter()
events = emitter.start("msg_1", "m")
events += emitter.feed({"type": "content", "text": "<think>Thinking."})
events += emitter.feed({"type": "content", "text": "<think>Thinking.</think>Answer."})
events += emitter.finish()
assert _emitter_client_text(events) == "<think>Thinking.</think>Answer."
def test_streamed_anthropic_tool_use_records_api_monitor_reply(monkeypatch):
import routes.inference as inf_mod
@ -889,6 +932,24 @@ class TestAnthropicToolNonStreaming:
assert tool_blocks[0]["name"] == "render_html"
assert tool_blocks[0]["input"] == {"code": "<!doctype html><html></html>"}
def test_display_strip_gates_on_declared_tools(self):
# A final answer containing NAME[ARGS]{json} is gated on the declared tools: undeclared
# ``foo`` markup is prose and survives, the declared web_search rehearsal strips.
def _run_gen():
yield {
"type": "content",
"text": 'Try foo[ARGS]{"x": 1} but not web_search[ARGS]{"q": "hi"} here.',
}
tools = [{"type": "function", "function": {"name": "web_search", "parameters": {}}}]
response = asyncio.run(
_anthropic_tool_non_streaming(_run_gen, "msg_1", "m", openai_tools = tools)
)
body = json.loads(response.body)
text = "".join(b["text"] for b in body["content"] if b["type"] == "text")
assert 'foo[ARGS]{"x": 1}' in text # inactive name preserved as prose
assert "web_search[ARGS]" not in text # active name stripped from display
# =====================================================================
# Pass-through emitter tests (client-side tool execution path)
@ -1709,3 +1770,187 @@ class TestAnthropicMessagesToolRouting:
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
assert backend.calls[0][0] == "plain"
def test_resumed_session_thinking_and_null_content_do_not_400():
# A resumed session replays assistant turns with `thinking` (and sometimes null)
# content. Those must be accepted (thinking dropped by the converter), not 400ed.
from pydantic import ValidationError
req = AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "secret reasoning", "signature": "s"},
{"type": "text", "text": "the answer"},
{"type": "tool_use", "id": "t1", "name": "f", "input": {}},
],
},
{"role": "assistant", "content": None}, # tool-only turn serialized as null
],
)
# Known blocks still parse as their typed models; only the unknown one is loose.
assert type(req.messages[1].content[0]).__name__ == "AnthropicUnknownBlock"
assert type(req.messages[1].content[1]).__name__ == "AnthropicTextBlock"
assert req.messages[2].content == "" # null coerced
openai = anthropic_messages_to_openai([m.model_dump() for m in req.messages])
assistant = next(m for m in openai if m["role"] == "assistant" and m.get("content"))
assert assistant["content"] == "the answer"
assert "secret reasoning" not in json.dumps(openai) # thinking never forwarded
# A malformed KNOWN block still fails cleanly instead of being swallowed.
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [{"role": "assistant", "content": [{"type": "tool_use", "name": "f"}]}],
)
def test_user_null_content_rejected():
# The null->"" leniency is assistant-only; a null user content must be rejected
# at the boundary, not coerced into an empty prompt and forwarded to the model.
from pydantic import ValidationError
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [{"role": "user", "content": None}],
)
def test_user_unknown_block_rejected_not_silently_dropped():
# The converter skips user blocks it cannot translate, so a user turn whose only
# block is unknown would validate yet forward no content. Reject at the boundary
# to avoid that silent data loss (the assistant fallback is unaffected).
from pydantic import ValidationError
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{"role": "user", "content": [{"type": "document", "source": {}}]},
],
)
def test_user_translatable_blocks_still_accepted():
# text / image / tool_result are translatable, so a real user message built from
# them must still pass; the unknown-block guard only trips on other types.
req = AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is this?"},
{
"type": "image",
"source": {"type": "base64", "media_type": "image/png", "data": "AA"},
},
{"type": "tool_result", "tool_use_id": "t1", "content": "ok"},
],
}
],
)
assert [type(b).__name__ for b in req.messages[0].content] == [
"AnthropicTextBlock",
"AnthropicImageBlock",
"AnthropicToolResultBlock",
]
openai = anthropic_messages_to_openai([m.model_dump() for m in req.messages])
assert any(m["role"] == "tool" and m["tool_call_id"] == "t1" for m in openai)
def test_user_malformed_known_block_still_rejected():
# The guard only allow-lists a user block's *type*; the union still validates its
# shape, so a known-but-malformed block (tool_result without tool_use_id) fails.
from pydantic import ValidationError
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{"role": "user", "content": [{"type": "tool_result", "content": "x"}]},
],
)
def test_user_content_block_non_string_type_rejected_cleanly():
# A user block whose `type` is a non-string (unhashable list / dict, or a stray
# int) must fail as a clean validation error, not raise TypeError from the
# frozenset membership test and escape as a 500.
from pydantic import ValidationError
for bad_type in ([], {}, 5):
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [{"role": "user", "content": [{"type": bad_type}]}],
)
def test_assistant_missing_content_key_still_rejected():
# The null -> "" leniency is only for an EXPLICIT null. An assistant message that
# omits content entirely stays malformed and must fail required-field validation.
from pydantic import ValidationError
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [{"role": "assistant"}],
)
# An explicit null is still accepted and coerced (regression guard).
req = AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": None},
],
)
assert req.messages[1].content == ""
def test_resumed_null_assistant_between_users_coalesced_on_messages_route(monkeypatch):
# user -> assistant(null) -> user is now accepted: the null assistant turn coerces
# to "" and is dropped. The route must then coalesce the two remaining user turns
# so a strict GGUF chat template does not 400 on non-alternating roles.
backend = _mock_backend(monkeypatch, context_length = 2048)
class _Req:
state = SimpleNamespace()
url = SimpleNamespace(path = "/v1/messages")
method = "POST"
async def is_disconnected(self):
return False
payload = AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{"role": "user", "content": "first question"},
{"role": "assistant", "content": None},
{"role": "user", "content": "please continue"},
],
)
response = _drive(anthropic_messages(payload, request = _Req(), current_subject = "t"))
assert response.status_code == 200
[(_path, kwargs)] = backend.calls
user_turns = [m for m in kwargs["messages"] if m.get("role") == "user"]
assert len(user_turns) == 1 # the two user turns were merged, not left adjacent
merged = user_turns[0]["content"]
if isinstance(merged, list):
merged = " ".join(p.get("text", "") for p in merged if isinstance(p, dict))
assert "first question" in merged and "please continue" in merged

View file

@ -0,0 +1,194 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Mapper models whose own tokenizer ships no chat_template have their turn-end
eos resolved at LOAD from an empty template (document eos only). The effective
template is installed later, at generate time, via get_chat_template, so the
turn-end-eos cache must be refreshed then; otherwise generate_stream runs past
the ChatML <|im_end|> boundary and loops (the exact bug this PR fixes).
"""
import sys
from pathlib import Path
import pytest
_BACKEND = Path(__file__).resolve().parent.parent
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
# These tests construct InferenceBackend, pulling the full stack. CI may lack
# unsloth/unsloth_zoo (ImportError) or have a broken CUDA/bitsandbytes setup
# (RuntimeError); skip at module level so collection is not aborted (exit 2).
try:
from core.inference import inference as inf_mod # noqa: E402
from core.inference.inference import InferenceBackend # noqa: E402
except (ImportError, RuntimeError) as exc: # pragma: no cover - env-dependent
pytest.skip(
f"full inference backend unavailable ({type(exc).__name__}: {exc})",
allow_module_level = True,
)
_CHATML = "{% for m in messages %}<|im_start|>{{m.role}}\n{{m.content}}<|im_end|>{% endfor %}"
_GEMMA = "{% for m in messages %}<start_of_turn>{{m.role}}\n{{m.content}}<end_of_turn>{% endfor %}"
class _FakeTokenizer:
def __init__(
self,
eos_id,
chat_template = "",
token_ids = None,
):
self.eos_token_id = eos_id
self.chat_template = chat_template
self.pad_token_id = eos_id
self.unk_token_id = None
self._ids = dict(token_ids or {})
def convert_tokens_to_ids(self, tok):
return self._ids.get(tok)
def test_turn_end_eos_refreshed_after_generate_time_template(monkeypatch):
import utils.datasets as ds
backend = InferenceBackend.__new__(InferenceBackend)
backend.active_model_name = "unsloth/qwen2.5-0.5b"
# No chat_template at load, so the cache stored only the document eos, though
# <|im_end|> is atomic in the vocab (unused until the mapper installs a template).
bare_tok = _FakeTokenizer(151643, chat_template = "", token_ids = {"<|im_end|>": 151645})
model_info = {
"tokenizer": bare_tok,
"is_vision": False,
"chat_turn_end_eos_ids": [151643],
}
backend.models = {backend.active_model_name: model_info}
# The mapper installs a ChatML template (turns end with <|im_end|>) at generate time.
templated_tok = _FakeTokenizer(151643, chat_template = _CHATML, token_ids = {"<|im_end|>": 151645})
monkeypatch.setattr(inf_mod, "get_chat_template", lambda tok, chat_template = None: templated_tok)
monkeypatch.setattr(
ds, "MODEL_TO_TEMPLATE_MAPPER", {backend.active_model_name: "qwen-2.5"}, raising = False
)
# Stub the tail so the generator runs through the refresh without a real model.
monkeypatch.setattr(backend, "_normalize_top_k", lambda k: k, raising = False)
monkeypatch.setattr(
backend, "_apply_chat_template_for_generation", lambda *a, **k: "PROMPT", raising = False
)
monkeypatch.setattr(backend, "generate_stream", lambda *a, **k: iter(()), raising = False)
list(backend._generate_chat_response_inner(messages = [{"role": "user", "content": "hi"}]))
# After the template is applied the cache must include the ChatML turn-end id.
assert model_info["chat_turn_end_eos_ids"] == [151643, 151645]
def test_turn_end_eos_refresh_preserves_load_time_ids_on_destructive_swap(monkeypatch):
# Regression: get_chat_template can return a remapped tokenizer (Gemma: <end_of_turn>
# folded onto the eos id) while generate_stream re-reads the original. Resolving on
# the swap yields a narrower set, so the refresh must UNION, never overwrite.
import utils.datasets as ds
backend = InferenceBackend.__new__(InferenceBackend)
backend.active_model_name = "unsloth/gemma-2b-it"
# Original tokenizer (used by generate_stream): <end_of_turn>=107 distinct from
# eos=1, so the load-time cache resolved to [1, 107].
orig_tok = _FakeTokenizer(1, chat_template = _GEMMA, token_ids = {"<end_of_turn>": 107})
model_info = {
"tokenizer": orig_tok,
"is_vision": False,
"chat_turn_end_eos_ids": [1, 107],
}
backend.models = {backend.active_model_name: model_info}
# Destructively-swapped tokenizer: <end_of_turn> now maps onto eos id 1, so
# resolving on it yields only [1] (drops 107).
swapped_tok = _FakeTokenizer(1, chat_template = _GEMMA, token_ids = {"<end_of_turn>": 1})
monkeypatch.setattr(inf_mod, "get_chat_template", lambda tok, chat_template = None: swapped_tok)
monkeypatch.setattr(
ds, "MODEL_TO_TEMPLATE_MAPPER", {backend.active_model_name: "gemma-3"}, raising = False
)
monkeypatch.setattr(backend, "_normalize_top_k", lambda k: k, raising = False)
monkeypatch.setattr(
backend, "_apply_chat_template_for_generation", lambda *a, **k: "PROMPT", raising = False
)
monkeypatch.setattr(backend, "generate_stream", lambda *a, **k: iter(()), raising = False)
list(backend._generate_chat_response_inner(messages = [{"role": "user", "content": "hi"}]))
# The load-time <end_of_turn>=107 must survive: overwriting with the swapped
# [1] would regress and loop past the turn.
assert model_info["chat_turn_end_eos_ids"] == [1, 107]
def test_turn_end_eos_refresh_resolves_marker_id_on_original_not_remapped(monkeypatch):
# Yi-style map_eos_token=True: the original carries <|im_end|> at its own id, but
# get_chat_template folds it onto the doc-eos id. generate_stream uses the original,
# so read marker strings from the mapped template but ids from the original.
import utils.datasets as ds
backend = InferenceBackend.__new__(InferenceBackend)
backend.active_model_name = "01-ai/yi-6b"
# Original: no template of its own, doc eos = 2, <|im_end|> atomic = 7.
orig_tok = _FakeTokenizer(2, chat_template = "", token_ids = {"<|im_end|>": 7})
model_info = {
"tokenizer": orig_tok,
"is_vision": False,
"chat_turn_end_eos_ids": [2],
}
backend.models = {backend.active_model_name: model_info}
# Remapped tokenizer: ChatML template, but <|im_end|> folded onto doc-eos id 2.
remapped_tok = _FakeTokenizer(2, chat_template = _CHATML, token_ids = {"<|im_end|>": 2})
monkeypatch.setattr(inf_mod, "get_chat_template", lambda tok, chat_template = None: remapped_tok)
monkeypatch.setattr(
ds, "MODEL_TO_TEMPLATE_MAPPER", {backend.active_model_name: "chatml"}, raising = False
)
monkeypatch.setattr(backend, "_normalize_top_k", lambda k: k, raising = False)
monkeypatch.setattr(
backend, "_apply_chat_template_for_generation", lambda *a, **k: "PROMPT", raising = False
)
monkeypatch.setattr(backend, "generate_stream", lambda *a, **k: iter(()), raising = False)
list(backend._generate_chat_response_inner(messages = [{"role": "user", "content": "hi"}]))
# The real <|im_end|>=7 (original vocab) must be recovered, not the remapped 2.
assert model_info["chat_turn_end_eos_ids"] == [2, 7]
class _FakeProcessor:
"""A ProcessorMixin-like container: carries the chat_template itself and
wraps the real text tokenizer as ``.tokenizer`` (the vision layout)."""
def __init__(self, chat_template, tokenizer):
self.chat_template = chat_template
self.tokenizer = tokenizer
def test_resolve_chat_eos_reads_vision_processor_template():
# Vision model: the chat_template lives on the processor while the inner tokenizer
# ships none. _resolve_chat_eos must read the marker from the processor but resolve
# its id on the inner tokenizer, and repair generation_config.
from types import SimpleNamespace
inner_tok = _FakeTokenizer(1, chat_template = "", token_ids = {"<end_of_turn>": 107})
processor = _FakeProcessor(_GEMMA, inner_tok)
model = SimpleNamespace(generation_config = SimpleNamespace(eos_token_id = 1))
backend = InferenceBackend.__new__(InferenceBackend)
backend.active_model_name = "unsloth/gemma-3-4b-it"
model_info = {"model": model, "tokenizer": processor, "processor": processor, "is_vision": True}
backend.models = {backend.active_model_name: model_info}
backend._resolve_chat_eos(backend.active_model_name)
assert model_info["chat_turn_end_eos_ids"] == [1, 107]
# generation_config repaired so the vision .generate() path stops at the turn.
assert model.generation_config.eos_token_id == [1, 107]

View file

@ -91,6 +91,17 @@ def test_chat_settings_payload_accepts_fast_mode_presets():
assert dumped["customPresets"][0]["params"]["fastMode"] is True
def test_chat_settings_payload_accepts_nudge_tool_calls():
# extra="forbid" 400s PUT /api/chat/settings on unknown keys, so the
# frontend's persisted nudgeToolCalls needs a payload field (like
# autoHealToolCalls).
payload = chat_history.ChatSettingsPayload.model_validate(
{"autoHealToolCalls": True, "nudgeToolCalls": False}
)
dumped = payload.model_dump(exclude_unset = True)
assert dumped == {"autoHealToolCalls": True, "nudgeToolCalls": False}
def test_chat_inference_settings_covers_frontend_persisted_fields():
# Drift guard: every InferenceParams field the UI persists (all but
# checkpoint) must exist on ChatInferenceSettings, else extra="forbid"

View file

@ -4,6 +4,7 @@
import os
import platform
import shutil
import sqlite3
import threading
import uuid
from pathlib import Path
@ -11,6 +12,7 @@ from pathlib import Path
import pytest
from storage import studio_db
from utils.paths import studio_db_path
def _reset_studio_db(
@ -108,6 +110,138 @@ def test_sync_chat_messages_upserts_without_pruning(tmp_path, monkeypatch):
assert by_id["msg-2"]["content"] == [{"type": "text", "text": "updated text"}]
def test_chat_thread_updated_at_bumps_on_message_writes(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
thread = studio_db.upsert_chat_thread(_thread())
assert thread["updatedAt"] == thread["createdAt"]
studio_db.upsert_chat_message(_message("msg-1", 1_700_000_000_500, "hi"))
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == 1_700_000_000_500
studio_db.upsert_chat_message(_message("msg-0", 1_600_000_000_000, "old"))
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == 1_700_000_000_500
studio_db.sync_chat_messages(
"thread-1",
[_message("msg-2", 1_700_000_001_000, "newer")],
)
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == 1_700_000_001_000
def test_chat_thread_updated_at_recomputed_when_pruning(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
thread = studio_db.upsert_chat_thread(_thread())
studio_db.sync_chat_messages(
"thread-1",
[
_message("msg-1", 1_700_000_000_500, "older"),
_message("msg-2", 1_700_000_001_000, "newest"),
],
prune_missing = True,
)
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == 1_700_000_001_000
# Pruning the newest message must lower updated_at to the remaining one.
studio_db.sync_chat_messages(
"thread-1",
[_message("msg-1", 1_700_000_000_500, "older")],
prune_missing = True,
)
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == 1_700_000_000_500
# Pruning every message falls back to created_at.
studio_db.sync_chat_messages("thread-1", [], prune_missing = True)
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == thread["createdAt"]
def test_chat_thread_updated_at_survives_thread_resave(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_thread(_thread())
studio_db.upsert_chat_message(_message("msg-1", 1_700_000_000_500, "hi"))
studio_db.upsert_chat_thread(_thread())
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == 1_700_000_000_500
def test_list_chat_threads_orders_by_last_activity(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
older = _thread("thread-old")
older["createdAt"] = 1_700_000_000_000
newer = _thread("thread-new")
newer["createdAt"] = 1_700_000_100_000
studio_db.upsert_chat_thread(older)
studio_db.upsert_chat_thread(newer)
assert [t["id"] for t in studio_db.list_chat_threads()] == ["thread-new", "thread-old"]
studio_db.upsert_chat_message(
_message("msg-1", 1_700_000_200_000, "hi", thread_id = "thread-old")
)
assert [t["id"] for t in studio_db.list_chat_threads()] == ["thread-old", "thread-new"]
def test_chat_threads_updated_at_migration_backfills_from_messages(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
db_path = studio_db_path()
db_path.parent.mkdir(parents = True, exist_ok = True)
conn = sqlite3.connect(str(db_path))
try:
conn.execute(
"""
CREATE TABLE chat_threads (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
model_type TEXT NOT NULL,
model_id TEXT,
pair_id TEXT,
archived INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE chat_messages (
id TEXT NOT NULL PRIMARY KEY,
thread_id TEXT NOT NULL,
parent_id TEXT,
role TEXT NOT NULL,
content_json TEXT NOT NULL,
attachments_json TEXT,
metadata_json TEXT,
created_at INTEGER NOT NULL
)
"""
)
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, created_at) VALUES (?, ?, ?, ?)",
("thread-with-msgs", "Old", "base", 1_700_000_000_000),
)
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, created_at) VALUES (?, ?, ?, ?)",
("thread-empty", "Empty", "base", 1_700_000_050_000),
)
# Fork-like thread: copied ancestor messages predate the thread itself.
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, created_at) VALUES (?, ?, ?, ?)",
("thread-fork", "Fork", "base", 1_700_000_100_000),
)
conn.executemany(
"INSERT INTO chat_messages (id, thread_id, role, content_json, created_at) VALUES (?, ?, ?, ?, ?)",
[
("m1", "thread-with-msgs", "user", "[]", 1_700_000_001_000),
("m2", "thread-with-msgs", "assistant", "[]", 1_700_000_002_000),
("m3", "thread-fork", "user", "[]", 1_700_000_001_000),
],
)
conn.commit()
finally:
conn.close()
assert studio_db.get_chat_thread("thread-with-msgs")["updatedAt"] == 1_700_000_002_000
assert studio_db.get_chat_thread("thread-empty")["updatedAt"] == 1_700_000_050_000
assert studio_db.get_chat_thread("thread-fork")["updatedAt"] == 1_700_000_100_000
def test_chat_projects_delete_cascades_threads_and_messages(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
project = studio_db.upsert_chat_project(_project())

View file

@ -0,0 +1,157 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""apply_chat_template_for_generation must coerce assistant tool_call arguments
from the OpenAI JSON-string form to a dict before rendering. Strict tool
templates (e.g. mlx-community Qwen3.5 checkpoints) iterate arguments.items() and
raise "Can only get item pairs from a mapping." on the string form when a prior
tool call is re-rendered on the next turn (MLX + transformers paths).
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
_BACKEND = Path(__file__).resolve().parent.parent
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
from core.inference.chat_template_helpers import ( # noqa: E402
_normalize_tool_call_arguments,
apply_chat_template_for_generation,
)
def _conv(arguments):
return [
{"role": "user", "content": "weather?"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"type": "function",
"id": "c1",
"function": {"name": "web_search", "arguments": arguments},
}
],
},
{"role": "tool", "name": "web_search", "content": "21C sunny"},
]
class _StrictTemplateTokenizer:
"""Mimics a strict Qwen tool template: rejects string tool_call arguments."""
def apply_chat_template(
self,
messages,
*,
tokenize = False,
add_generation_prompt = True,
**kw,
):
for msg in messages:
for call in msg.get("tool_calls", []) or []:
args = call.get("function", {}).get("arguments")
if isinstance(args, str):
raise TypeError("Can only get item pairs from a mapping.")
return "RENDERED"
def test_string_arguments_are_parsed_to_dict():
out = _normalize_tool_call_arguments(_conv('{"query": "sweden"}'))
args = out[1]["tool_calls"][0]["function"]["arguments"]
assert args == {"query": "sweden"}
def test_dict_arguments_untouched_and_no_copy():
conv = _conv({"query": "sweden"})
assert _normalize_tool_call_arguments(conv) is conv
def test_non_json_string_left_as_is():
out = _normalize_tool_call_arguments(_conv("not json"))
assert out[1]["tool_calls"][0]["function"]["arguments"] == "not json"
def test_render_succeeds_on_strict_template_with_string_arguments():
# Regression: strict template + string args used to raise.
result = apply_chat_template_for_generation(_StrictTemplateTokenizer(), _conv('{"query": "x"}'))
assert result == "RENDERED"
class _RecordingTokenizer:
"""Lenient template: renders whatever arguments it is given (string or dict)."""
def __init__(self):
self.seen_arguments = None
def apply_chat_template(
self,
messages,
*,
tokenize = False,
add_generation_prompt = True,
**kw,
):
for msg in messages:
for call in msg.get("tool_calls", []) or []:
self.seen_arguments = call.get("function", {}).get("arguments")
return "RENDERED"
def test_lenient_template_receives_original_string_untouched():
# Lenient template must see the exact original string, not a coerced dict.
tok = _RecordingTokenizer()
apply_chat_template_for_generation(tok, _conv('{"query": "x"}'))
assert tok.seen_arguments == '{"query": "x"}'
def test_messages_without_tool_calls_pass_through_unchanged():
conv = [{"role": "user", "content": "hi"}]
assert _normalize_tool_call_arguments(conv) is conv
class _RaiseExceptionTemplateTokenizer:
"""Mimics the bundled gemma-4.jinja: rejects string tool_call arguments via
``raise_exception(...)``, which surfaces as a Jinja error, NOT a TypeError."""
def apply_chat_template(
self,
messages,
*,
tokenize = False,
add_generation_prompt = True,
**kw,
):
for msg in messages:
for call in msg.get("tool_calls", []) or []:
args = call.get("function", {}).get("arguments")
if isinstance(args, str):
raise ValueError(
"chat_template: tool_calls[].function.arguments must be a "
"JSON object (mapping), not a string."
)
return "RENDERED"
def test_render_succeeds_on_raise_exception_template_with_string_arguments():
# Regression: gemma-4.jinja rejects string args via a non-TypeError; retry must still coerce.
result = apply_chat_template_for_generation(
_RaiseExceptionTemplateTokenizer(), _conv('{"query": "x"}')
)
assert result == "RENDERED"
def test_unrelated_template_error_still_propagates_with_dict_args():
# Failure unrelated to string args (dict args, nothing to coerce) must propagate.
class _AlwaysRaises:
def apply_chat_template(self, messages, **kw):
raise ValueError("template is broken")
with pytest.raises(ValueError, match = "broken"):
apply_chat_template_for_generation(_AlwaysRaises(), _conv({"query": "x"}))

View file

@ -0,0 +1,150 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""chat_eos: resolve assistant-turn-end stop tokens from the chat_template and
repair generation_config so a chat model whose eos is a bare document terminator
(Qwen3.5: config eos <|endoftext|>, turns end with <|im_end|>) stops at the turn
boundary instead of running past it and looping. Dependency-light: imported here
without the full inference stack.
"""
from __future__ import annotations
import sys
from pathlib import Path
_BACKEND = Path(__file__).resolve().parent.parent
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
from core.inference.chat_eos import ( # noqa: E402
chat_eos_repair,
resolve_chat_turn_end_eos_ids,
resolve_chat_turn_end_eos_ids_using,
)
class _FakeTokenizer:
def __init__(
self,
eos_id,
chat_template = "",
token_ids = None,
unk_token_id = None,
):
self.eos_token_id = eos_id
self.chat_template = chat_template
self.unk_token_id = unk_token_id
self._ids = dict(token_ids or {})
def convert_tokens_to_ids(self, tok):
return self._ids.get(tok, self.unk_token_id)
# ---- resolve_chat_turn_end_eos_ids ---------------------------------------
_CHATML = "{% for m in messages %}<|im_start|>{{m.role}}\n{{m.content}}<|im_end|>{% endfor %}"
def test_qwen35_adds_im_end_from_template():
# eos synced to <|endoftext|> (248044); template uses <|im_end|> (248046).
tok = _FakeTokenizer(248044, chat_template = _CHATML, token_ids = {"<|im_end|>": 248046})
assert resolve_chat_turn_end_eos_ids(tok) == [248044, 248046]
def test_marker_in_vocab_but_not_in_template_is_ignored():
# Base/coder model: <|im_end|> is in the vocab but the template does not use
# it, so it must not become a stop token.
tok = _FakeTokenizer(248044, chat_template = "{{ messages }}", token_ids = {"<|im_end|>": 248046})
assert resolve_chat_turn_end_eos_ids(tok) == [248044]
def test_harmony_template_is_left_untouched():
# gpt-oss/harmony: <|end|> is a channel delimiter, not the turn end.
harmony = "<|start|>assistant<|channel|>analysis<|message|>...<|end|>"
tok = _FakeTokenizer(200002, chat_template = harmony, token_ids = {"<|end|>": 200007})
assert resolve_chat_turn_end_eos_ids(tok) == [200002]
def test_llama3_eot_id_from_template():
tok = _FakeTokenizer(128001, chat_template = "...<|eot_id|>...", token_ids = {"<|eot_id|>": 128009})
assert resolve_chat_turn_end_eos_ids(tok) == [128001, 128009]
def test_gemma4_turn_marker_from_template():
# Gemma-4 ends turns with <turn|> while keeping a document eos, so <turn|> must
# be added as a stop token.
tok = _FakeTokenizer(
1, chat_template = "...<start_of_turn>...<turn|>...", token_ids = {"<turn|>": 106}
)
assert resolve_chat_turn_end_eos_ids(tok) == [1, 106]
def test_resolve_using_reads_markers_from_template_but_ids_from_generation_tokenizer():
# map_eos_token=True: the mapped template remaps <|im_end|> onto the doc-eos id,
# but the original keeps it atomic. Reading marker STRINGS from the template but
# IDS on the original recovers the real turn-end id (7), not the doc-eos id (2).
template_tok = _FakeTokenizer(2, chat_template = _CHATML, token_ids = {"<|im_end|>": 2})
id_tok = _FakeTokenizer(2, chat_template = "", token_ids = {"<|im_end|>": 7})
assert resolve_chat_turn_end_eos_ids_using(template_tok, id_tok) == [2, 7]
# Same tokenizer for both reproduces the plain resolve (load-time behaviour).
assert resolve_chat_turn_end_eos_ids_using(template_tok, template_tok) == [2]
def test_list_eos_preserved():
tok = _FakeTokenizer([1, 2], chat_template = _CHATML, token_ids = {"<|im_end|>": 2})
assert resolve_chat_turn_end_eos_ids(tok) == [1, 2]
def test_missing_marker_maps_to_unk_and_is_skipped():
tok = _FakeTokenizer(7, chat_template = _CHATML, token_ids = {}, unk_token_id = 0)
assert resolve_chat_turn_end_eos_ids(tok) == [7]
def test_starling_barred_end_of_turn_from_template():
# OpenChat/Starling end turns with the BARRED <|end_of_turn|> (distinct from
# Gemma's <end_of_turn>). eos synced to </s>=2, turn marker at 32000.
starling = "GPT4 Correct Assistant: hi<|end_of_turn|>"
tok = _FakeTokenizer(2, chat_template = starling, token_ids = {"<|end_of_turn|>": 32000})
assert resolve_chat_turn_end_eos_ids(tok) == [2, 32000]
def test_dict_chat_template_scans_all_variants():
# Hermes-3 style: chat_template is a {name: template} dict. Detection must scan
# every variant, not bail because the container is not a plain str.
tmpl = {"default": "{{ messages }}", "tool_use": _CHATML}
tok = _FakeTokenizer(2, chat_template = tmpl, token_ids = {"<|im_end|>": 5})
assert resolve_chat_turn_end_eos_ids(tok) == [2, 5]
def test_list_of_dicts_chat_template_scans_all_variants():
# tokenizer_config.json stores multi-templates as a list of {name, template}.
tmpl = [{"name": "default", "template": _CHATML}]
tok = _FakeTokenizer(2, chat_template = tmpl, token_ids = {"<|im_end|>": 5})
assert resolve_chat_turn_end_eos_ids(tok) == [2, 5]
def test_dict_harmony_template_left_untouched():
# A multi-variant container whose variant is harmony must still be left alone.
tmpl = {"default": "<|start|>assistant<|channel|>analysis<|message|>...<|end|>"}
tok = _FakeTokenizer(200002, chat_template = tmpl, token_ids = {"<|end|>": 200007})
assert resolve_chat_turn_end_eos_ids(tok) == [200002]
# ---- chat_eos_repair ------------------------------------------------------
def test_repair_adds_missing_turn_end():
assert chat_eos_repair(248044, [248044, 248046]) == [248044, 248046]
def test_repair_from_missing_generation_config_eos():
assert chat_eos_repair(None, [248046]) == [248046]
def test_repair_noop_when_already_covered():
assert chat_eos_repair([248046, 248044], [248046]) is None
def test_repair_noop_when_no_turn_end_ids():
assert chat_eos_repair(248044, []) is None

View file

@ -0,0 +1,50 @@
# 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 coding-agent CLI detection used by the API-keys settings panel."""
from unittest.mock import patch
from utils.coding_agents import CODING_AGENTS, detect_installed_coding_agents
def test_matches_unsloth_start_subcommands():
# Each entry must be an actual `unsloth start <agent>` subcommand name
# (unsloth_cli/commands/start.py). Spelled out here rather than imported
# from that module, which pulls in the CLI's heavier dependencies.
assert CODING_AGENTS == ("claude", "codex", "openclaw", "opencode", "hermes", "pi")
def test_detects_only_agents_present_on_path():
installed = {"claude", "opencode"}
with patch(
"utils.coding_agents.shutil.which",
side_effect = lambda name: f"/usr/bin/{name}" if name in installed else None,
):
assert detect_installed_coding_agents() == ["claude", "opencode"]
def test_returns_empty_list_when_nothing_is_installed():
with patch("utils.coding_agents.shutil.which", return_value = None):
assert detect_installed_coding_agents() == []
def test_preserves_declared_order_regardless_of_path_lookup_order():
with patch(
"utils.coding_agents.shutil.which",
side_effect = lambda name: name if name in ("pi", "claude", "hermes") else None,
):
assert detect_installed_coding_agents() == ["claude", "hermes", "pi"]
def test_treats_a_path_lookup_error_as_not_installed():
# An advisory check: shutil.which raising for one entry (e.g. a permission
# error walking a PATH directory) should not take down the whole endpoint,
# and should not stop the remaining agents from being checked.
def flaky_which(name: str):
if name == "codex":
raise OSError("permission denied")
return name if name == "claude" else None
with patch("utils.coding_agents.shutil.which", side_effect = flaky_which):
assert detect_installed_coding_agents() == ["claude"]

View file

@ -0,0 +1,314 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Completion-only masking policy: auto-detect first, manual table fallback.
Covers utils.datasets.completion_masking.apply_completion_masking, shared by
the CUDA trainer (core/training/trainer.py) and the MLX worker
(core/training/worker.py):
- unmapped models use chat template auto-detection (previously masking was
silently disabled),
- gpt-oss goes auto-first too (its quantized checkpoints ship a template
the manual markers cannot match),
- an auto-detection failure falls back to the template table markers,
- a table miss after an auto failure warns and leaves the trainer unchanged.
"""
from __future__ import annotations
import pytest
from utils.datasets.completion_masking import apply_completion_masking, lookup_manual_markers
from utils.datasets.model_mappings import TEMPLATE_TO_RESPONSES_MAPPER
class _Trainer:
"""Sentinel trainer; train_fn wraps it in a new object when applied."""
class _Recorder:
"""Fake train_on_responses_only that records calls."""
def __init__(self):
self.calls = []
def __call__(self, trainer, **kwargs):
self.calls.append(kwargs)
wrapped = _Trainer()
wrapped.wrapped_from = trainer
return wrapped
def _detect_ok(processor):
return "<INS>", "<RES>"
def _detect_fail(processor):
raise ValueError(
"Unsloth: Could not reliably auto-detect response_part - "
"pass instruction_part and response_part."
)
_AUTO = {"instruction_part": "<INS>", "response_part": "<RES>"}
class _Notes:
def __init__(self):
self.messages = []
def __call__(self, level, message):
self.messages.append((level, message))
def warnings(self):
return [m for level, m in self.messages if level == "warning"]
def test_unmapped_model_uses_auto_detection():
# Unmapped model: the auto path applies masking (was silently disabled).
trainer = _Trainer()
train_fn = _Recorder()
notes = _Notes()
result, applied = apply_completion_masking(
trainer, "LiquidAI/LFM2-8B-A1B", train_fn, notify = notes, detect_fn = _detect_ok
)
assert applied is True
assert result.wrapped_from is trainer
assert train_fn.calls == [dict(_AUTO)] # applied with the detected markers
assert notes.warnings() == []
def test_mapped_model_prefers_auto_detection():
trainer = _Trainer()
train_fn = _Recorder()
_, applied = apply_completion_masking(
trainer, "unsloth/Qwen3-0.6B", train_fn, detect_fn = _detect_ok
)
assert applied is True
assert train_fn.calls == [dict(_AUTO)]
def test_gpt_oss_uses_auto_detection_first():
# The quantized gpt-oss checkpoints ship a template without the
# <|channel|>final header, where the manual markers match nothing; auto
# derives markers from the template the checkpoint actually ships.
trainer = _Trainer()
train_fn = _Recorder()
_, applied = apply_completion_masking(
trainer, "unsloth/gpt-oss-20b", train_fn, detect_fn = _detect_ok
)
assert applied is True
assert train_fn.calls == [dict(_AUTO)]
def test_gpt_oss_detection_failure_falls_back_to_manual_markers():
trainer = _Trainer()
train_fn = _Recorder()
_, applied = apply_completion_masking(
trainer, "unsloth/gpt-oss-20b", train_fn, detect_fn = _detect_fail
)
assert applied is True
expected = TEMPLATE_TO_RESPONSES_MAPPER["gpt-oss"]
assert train_fn.calls == [
{
"instruction_part": expected["instruction"],
"response_part": expected["response"],
}
]
def test_auto_failure_falls_back_to_template_table():
trainer = _Trainer()
train_fn = _Recorder()
notes = _Notes()
result, applied = apply_completion_masking(
trainer, "unsloth/Qwen3-0.6B", train_fn, notify = notes, detect_fn = _detect_fail
)
assert applied is True
assert result.wrapped_from is trainer
expected = TEMPLATE_TO_RESPONSES_MAPPER["qwen3"]
assert train_fn.calls == [
{
"instruction_part": expected["instruction"],
"response_part": expected["response"],
},
]
assert any("falling back to the template table" in m for m in notes.warnings())
def test_application_failure_propagates_not_fallback():
# Detection succeeds; a failure while APPLYING the masking must propagate,
# never silently fall back to full-sequence training.
def train_fn(trainer, **kwargs):
raise RuntimeError("dataset map worker crashed")
with pytest.raises(RuntimeError, match = "dataset map worker crashed"):
apply_completion_masking(_Trainer(), "LiquidAI/LFM2-8B-A1B", train_fn, detect_fn = _detect_ok)
def test_preset_tokenizer_markers_used_directly():
# Preset unsloth marker attrs skip detection; zoo reuses them on a bare call.
class _Tok:
_unsloth_input_part = "<I>"
_unsloth_output_part = "<O>"
trainer = _Trainer()
trainer.processing_class = _Tok()
train_fn = _Recorder()
_, applied = apply_completion_masking(
trainer, "LiquidAI/LFM2-8B-A1B", train_fn, detect_fn = _detect_fail
)
assert applied is True
assert train_fn.calls == [{}] # bare call, stored parts
def test_table_miss_warns_and_disables_without_crashing():
trainer = _Trainer()
train_fn = _Recorder()
notes = _Notes()
result, applied = apply_completion_masking(
trainer, "some-org/not-in-any-mapper", train_fn, notify = notes, detect_fn = _detect_fail
)
assert applied is False
assert result is trainer # unchanged: full sequence training
assert train_fn.calls == [] # detection failed; nothing applied
assert any("could not be applied" in m for m in notes.warnings())
assert any("full sequences" in m for m in notes.warnings())
def test_num_proc_forwarded_only_when_given():
# CUDA path passes num_proc; the MLX path omits it.
train_fn = _Recorder()
apply_completion_masking(
_Trainer(), "unsloth/Qwen3-0.6B", train_fn, num_proc = 4, detect_fn = _detect_ok
)
assert train_fn.calls == [dict(_AUTO, num_proc = 4)]
train_fn = _Recorder()
apply_completion_masking(
_Trainer(), "unsloth/Qwen3-0.6B", train_fn, num_proc = 4, detect_fn = _detect_fail
)
assert train_fn.calls[0]["num_proc"] == 4
train_fn = _Recorder()
apply_completion_masking(_Trainer(), "unsloth/Qwen3-0.6B", train_fn, detect_fn = _detect_ok)
assert train_fn.calls == [dict(_AUTO)]
def test_manual_fallback_failure_propagates_to_caller():
# Errors while applying the manual fallback must propagate to the caller.
def train_fn(trainer, **kwargs):
raise RuntimeError("boom")
with pytest.raises(RuntimeError, match = "boom"):
apply_completion_masking(_Trainer(), "unsloth/gpt-oss-20b", train_fn)
def test_notify_is_optional():
train_fn = _Recorder()
_, applied = apply_completion_masking(
_Trainer(), "some-org/not-in-any-mapper", train_fn, detect_fn = _detect_fail
)
assert applied is False
def test_lookup_manual_markers():
template, instruction, response = lookup_manual_markers("unsloth/Qwen3-0.6B")
assert template == "qwen3"
assert instruction == TEMPLATE_TO_RESPONSES_MAPPER["qwen3"]["instruction"]
assert response == TEMPLATE_TO_RESPONSES_MAPPER["qwen3"]["response"]
template, instruction, response = lookup_manual_markers("some-org/unknown")
assert (template, instruction, response) == (None, None, None)
template, instruction, response = lookup_manual_markers(None)
assert (template, instruction, response) == (None, None, None)
def test_renamed_gpt_oss_gets_template_markers():
# Name-detected as gpt-oss but not in the exact-name table: must use the
# gpt-oss markers, not fall through to full-sequence training.
trainer = _Trainer()
train_fn = _Recorder()
_, applied = apply_completion_masking(
trainer, "some-org/gpt-oss-20b-sft", train_fn, detect_fn = _detect_fail
)
assert applied is True
expected = TEMPLATE_TO_RESPONSES_MAPPER["gpt-oss"]
assert train_fn.calls == [
{
"instruction_part": expected["instruction"],
"response_part": expected["response"],
}
]
class _FakeTokenizerWrapper:
"""mlx-lm TokenizerWrapper semantics: plain reads delegate to the wrapped
tokenizer, underscore attrs do not (so preset markers are hidden)."""
def __init__(self, tokenizer):
object.__setattr__(self, "_tokenizer", tokenizer)
def __getattr__(self, attr):
if attr.startswith("_"):
return object.__getattribute__(self, attr)
return getattr(object.__getattribute__(self, "_tokenizer"), attr)
_FakeTokenizerWrapper.__name__ = "TokenizerWrapper"
def test_mlx_tokenizer_wrapper_unwrapped_for_preset_markers():
# Markers live on the inner HF tokenizer that the wrapper hides; the helper
# must unwrap so the preset bare-call path still fires on MLX.
class _Tok:
_unsloth_input_part = "<I>"
_unsloth_output_part = "<O>"
trainer = _Trainer()
trainer.tokenizer = _FakeTokenizerWrapper(_Tok())
train_fn = _Recorder()
_, applied = apply_completion_masking(
trainer, "LiquidAI/LFM2-8B-A1B", train_fn, detect_fn = _detect_fail
)
assert applied is True
assert train_fn.calls == [{}] # bare call, stored parts
def test_mlx_tokenizer_wrapper_unwrapped_for_detection():
# Detection must see the real tokenizer, not the wrapper, so it does not
# depend on the loader's __call__ patch.
class _Tok:
pass
inner = _Tok()
trainer = _Trainer()
trainer.tokenizer = _FakeTokenizerWrapper(inner)
train_fn = _Recorder()
seen = []
def detect(processor):
seen.append(processor)
return "<INS>", "<RES>"
_, applied = apply_completion_masking(
trainer, "LiquidAI/LFM2-8B-A1B", train_fn, detect_fn = detect
)
assert applied is True
assert seen == [inner]

View file

@ -65,12 +65,14 @@ def _backend(
vocab = 248320,
embd = 5120,
mla = None,
arch = None,
):
"""Backend with just the dims the compute-buffer estimate reads."""
b = LlamaCppBackend.__new__(LlamaCppBackend)
b._vocab_size = vocab
b._embedding_length = embd
b._key_length_mla = mla # non-None -> MLA (compressed attention)
b._architecture = arch # GGUF general.architecture (e.g. 'deepseek4')
return b
@ -290,3 +292,62 @@ class TestContextBufferMLA:
b = _backend(embd = 6144, mla = 256)
est = b._compute_buffer_ctx_bytes(754688, cache_type_kv = "q8_0") / MIB
assert est <= 4141 * 1.7
class TestContextBufferDSV4:
"""DeepSeek-V4 (deepseek4) reserves a large lightning-indexer / sparse-attention
compute buffer the KQ-mask and MLA rates miss (present even with an f16 cache).
Measured on UD-Q4_K_XL (ub=512): ~2 GiB at 16k ctx, ~65.5 GiB at 1M. The auto-fit
must see this so it does not commit the full 1M train context and OOM (spilling
to CPU at ~4 tok/s)."""
_MEASURED_1M_GIB = 65.5 # 70353790464 B compute-graph reserve that OOM'd at 1M ctx
GIB = 1024**3
def test_covers_measured_1m_buffer(self):
b = _backend(embd = 4096, arch = "deepseek4")
gib = b._compute_buffer_ctx_bytes(1048576, cache_type_kv = "f16") / self.GIB
assert gib >= self._MEASURED_1M_GIB, f"under-reserved {gib:.1f} < {self._MEASURED_1M_GIB}"
def test_not_wildly_over_at_1m(self):
# Within ~1.3x of measured so the fit still grants a large (~256k) context.
b = _backend(embd = 4096, arch = "deepseek4")
gib = b._compute_buffer_ctx_bytes(1048576, cache_type_kv = "f16") / self.GIB
assert gib <= self._MEASURED_1M_GIB * 1.3
def test_fires_for_f16_cache(self):
# The bug: an f16 (default) cache took the tiny mask-only path. DSV4 must
# reserve GiB, not the ~MiB a non-DSV4 model reserves at the same ctx.
dsv4 = _backend(embd = 4096, arch = "deepseek4")._compute_buffer_ctx_bytes(
262144, cache_type_kv = "f16"
)
other = _backend(embd = 4096, arch = "qwen3")._compute_buffer_ctx_bytes(
262144, cache_type_kv = "f16"
)
assert dsv4 > 40 * other
def test_cache_type_independent(self):
# Indexer scratch is present for an f16 and a quantized cache alike.
b = _backend(embd = 4096, arch = "deepseek4")
assert b._compute_buffer_ctx_bytes(
262144, cache_type_kv = "f16"
) == b._compute_buffer_ctx_bytes(262144, cache_type_kv = "q8_0")
def test_flat_floor_at_small_ctx(self):
# ~2 GiB indexer scratch present even at tiny ctx (covers the measured 16k ~2 GiB).
b = _backend(embd = 4096, arch = "deepseek4")
assert b._compute_buffer_ctx_bytes(16384, cache_type_kv = "f16") / self.GIB >= 2.0
def test_scales_with_context_and_ubatch(self):
b = _backend(embd = 4096, arch = "deepseek4")
assert b._compute_buffer_ctx_bytes(131072) > b._compute_buffer_ctx_bytes(65536)
assert b._compute_buffer_ctx_bytes(131072, n_ubatch = 1024) > b._compute_buffer_ctx_bytes(
131072, n_ubatch = 256
)
def test_non_dsv4_unchanged(self):
# Regression guard: a non-deepseek4 model keeps the mask-only f16 rate.
b = _backend(embd = 4096, arch = "llama")
per_tok = b._compute_buffer_ctx_bytes(100000, cache_type_kv = "f16") / 100000
expected = 512 * 2 * LlamaCppBackend._CTX_COMPUTE_F16_MASK_SAFETY
assert per_tok == pytest.approx(expected, rel = 1e-6)

View file

@ -124,3 +124,100 @@ def test_unstructured_upload_import_errors_stay_generic(monkeypatch, tmp_path, e
assert result.status == "error"
assert result.error == "Text extraction failed."
assert _block_files(seed_route) == []
_TEST_UPLOAD_UID = "0f" * 16
def test_remove_unstructured_block_deletes_directory(monkeypatch, tmp_path):
seed_route = _load_seed_route(monkeypatch, tmp_path)
_run_upload(seed_route, "notes.txt", b"hello", block_id = _TEST_UPLOAD_UID)
assert _block_files(seed_route, _TEST_UPLOAD_UID) != []
result = asyncio.run(seed_route.remove_unstructured_block(_TEST_UPLOAD_UID))
assert result == {"status": "ok", "deleted": True}
assert not (seed_route.UNSTRUCTURED_UPLOAD_ROOT / _TEST_UPLOAD_UID).exists()
def test_remove_unstructured_block_missing_directory_is_ok(monkeypatch, tmp_path):
seed_route = _load_seed_route(monkeypatch, tmp_path)
result = asyncio.run(seed_route.remove_unstructured_block(_TEST_UPLOAD_UID))
assert result == {"status": "ok", "deleted": False}
def test_remove_unstructured_block_rejects_unsafe_ids(monkeypatch, tmp_path):
seed_route = _load_seed_route(monkeypatch, tmp_path)
with pytest.raises(seed_route.HTTPException) as exc:
asyncio.run(seed_route.remove_unstructured_block("../escape"))
assert exc.value.status_code == 400
def test_remove_unstructured_block_rejects_legacy_node_ids(monkeypatch, tmp_path):
seed_route = _load_seed_route(monkeypatch, tmp_path)
_run_upload(seed_route, "notes.txt", b"hello", block_id = "n1")
assert _block_files(seed_route, "n1") != []
with pytest.raises(seed_route.HTTPException) as exc:
asyncio.run(seed_route.remove_unstructured_block("n1"))
assert exc.value.status_code == 400
assert _block_files(seed_route, "n1") != []
def test_remove_unstructured_block_rejects_symlink_escape(monkeypatch, tmp_path):
seed_route = _load_seed_route(monkeypatch, tmp_path)
outside = tmp_path / "outside"
outside.mkdir()
(outside / "victim.txt").write_text("keep me")
root = seed_route.UNSTRUCTURED_UPLOAD_ROOT
root.mkdir(parents = True)
(root / _TEST_UPLOAD_UID).symlink_to(outside)
with pytest.raises(seed_route.HTTPException) as exc:
asyncio.run(seed_route.remove_unstructured_block(_TEST_UPLOAD_UID))
assert exc.value.status_code == 400
assert (outside / "victim.txt").exists()
def test_remove_unstructured_block_fails_if_directory_remains(monkeypatch, tmp_path):
seed_route = _load_seed_route(monkeypatch, tmp_path)
root = seed_route.UNSTRUCTURED_UPLOAD_ROOT
block_dir = root / _TEST_UPLOAD_UID
block_dir.mkdir(parents = True)
(block_dir / "victim.txt").write_text("keep me")
calls = []
def noop_rmtree(path, *args, **kwargs):
calls.append((path, args, kwargs))
monkeypatch.setattr(seed_route.shutil, "rmtree", noop_rmtree)
with pytest.raises(seed_route.HTTPException) as exc:
asyncio.run(seed_route.remove_unstructured_block(_TEST_UPLOAD_UID))
assert calls
assert exc.value.status_code == 500
assert block_dir.exists()
def test_total_upload_quota_is_scoped_per_block(monkeypatch, tmp_path):
seed_route = _load_seed_route(monkeypatch, tmp_path)
monkeypatch.setattr(seed_route, "UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_BYTES", 10)
first = _run_upload(seed_route, "a.txt", b"123456789")
assert first.status == "ok"
with pytest.raises(seed_route.HTTPException) as exc:
_run_upload(seed_route, "b.txt", b"123")
assert exc.value.status_code == 413
# Another block starts with its own untouched budget.
other = _run_upload(seed_route, "c.txt", b"123", block_id = "other")
assert other.status == "ok"

View file

@ -0,0 +1,181 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""DeepSeek-V4-Flash reasoning toggle: None / High / Max.
The GGUF template gates thinking with ``enable_thinking`` and only branches
``reasoning_effort`` on ``'max'`` (an escalation layered over plain thinking).
Detection used to return the single level ``['max']``, so the UI collapsed to
None / Max and the plain-thinking tier was unreachable. Detection now surfaces
``'high'`` as that plain tier, giving None / High / Max. These tests pin the
classifier, the GLM-style parity case, and the full request-kwargs -> rendered
prompt path for each state (the model itself is too large to load here).
"""
from __future__ import annotations
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
_backend_root = Path(__file__).resolve().parent.parent
if str(_backend_root) not in sys.path:
sys.path.insert(0, str(_backend_root))
# Faithful slice of the DeepSeek-V4-Flash GGUF template: the enable_thinking
# gate, the sole ``reasoning_effort == 'max'`` escalation, and the plain-think
# fallback. Any non-'max' effort renders as ordinary thinking.
DEEPSEEK_V4_TEMPLATE = """
{%- if not thinking is defined -%}
{%- if enable_thinking is defined -%}
{%- set thinking = enable_thinking -%}
{%- else -%}
{%- set thinking = false -%}
{%- endif -%}
{%- endif -%}
{%- if not reasoning_effort is defined -%}
{%- set reasoning_effort = none -%}
{%- endif -%}
{{- bos_token -}}
{%- if thinking and reasoning_effort == 'max' -%}
{{- 'Reasoning Effort: Absolute maximum with no shortcuts permitted.\\n\\n' -}}
{%- endif -%}
{%- for message in messages -%}
{{- '<|User|>' + (message['content'] or '') -}}
{%- endfor -%}
{%- if add_generation_prompt -%}
{{- '<|Assistant|>' -}}
{%- if thinking -%}{{- '<think>' -}}{%- else -%}{{- '</think>' -}}{%- endif -%}
{%- endif -%}
"""
# GLM-5.2-style: branches on two effort literals, so 'high' already exists as
# the sub-'max' tier and detection must leave the pair untouched.
GLM_STYLE_TEMPLATE = """
{%- if enable_thinking -%}
{%- if reasoning_effort == 'high' -%}{{- 'H' -}}
{%- elif reasoning_effort == 'max' -%}{{- 'M' -}}
{%- endif -%}
{%- endif -%}
"""
# A ['max']-only template under a non-deepseek id: the synthetic 'high' is scoped
# to deepseek-v4, so this must stay ['max'] (no phantom 'high').
NON_DEEPSEEK_MAX_ONLY_TEMPLATE = DEEPSEEK_V4_TEMPLATE
# A template whose sole effort literal is a sub-'max' level: the guard targets
# only the ['max']-alone case, so a lone 'high' stays a singleton.
HIGH_ONLY_TEMPLATE = """
{%- if enable_thinking and reasoning_effort == 'high' -%}{{- 'H' -}}{%- endif -%}
"""
def _render(template: str, **kwargs) -> str:
jinja2 = pytest.importorskip("jinja2")
env = jinja2.Environment()
tmpl = env.from_string(template)
return tmpl.render(bos_token = "<BOS>", add_generation_prompt = True, **kwargs)
# -- Classifier -------------------------------------------------------
def test_deepseek_v4_surfaces_high_as_plain_tier():
"""Sole 'max' escalation expands to ['high', 'max'] so None/High/Max show."""
from core.inference.llama_cpp import detect_reasoning_flags
flags = detect_reasoning_flags(DEEPSEEK_V4_TEMPLATE, "unsloth/DeepSeek-V4-Flash")
assert flags["supports_reasoning"] is True
assert flags["reasoning_style"] == "enable_thinking_effort"
assert flags["reasoning_effort_levels"] == ["high", "max"]
def test_glm_style_two_level_template_unchanged():
"""A template that already names a sub-'max' tier is left as-is."""
from core.inference.llama_cpp import detect_reasoning_flags
flags = detect_reasoning_flags(GLM_STYLE_TEMPLATE, "unsloth/GLM-5.2")
assert flags["reasoning_style"] == "enable_thinking_effort"
assert flags["reasoning_effort_levels"] == ["high", "max"]
def test_synthetic_high_scoped_to_deepseek_v4():
"""The same ['max']-only template under a non-deepseek id keeps ['max']."""
from core.inference.llama_cpp import detect_reasoning_flags
flags = detect_reasoning_flags(NON_DEEPSEEK_MAX_ONLY_TEMPLATE, "vendor/OtherHybrid-GGUF")
assert flags["reasoning_effort_levels"] == ["max"]
def test_guard_does_not_fire_for_sub_max_singleton():
"""The expansion targets only ['max']; a lone 'high' stays a singleton."""
from core.inference.llama_cpp import detect_reasoning_flags
flags = detect_reasoning_flags(HIGH_ONLY_TEMPLATE, "custom/high-only")
assert flags["reasoning_effort_levels"] == ["high"]
# -- Request kwargs -> rendered prompt, for each state ----------------
def _kwargs_for(flags: dict, enable_thinking, reasoning_effort):
"""Drive the real backend method with a shim carrying the detected flags."""
from core.inference.llama_cpp import LlamaCppBackend
shim = SimpleNamespace(
_supports_reasoning = flags["supports_reasoning"],
_reasoning_always_on = flags["reasoning_always_on"],
_reasoning_style = flags["reasoning_style"],
_reasoning_effort_levels = flags["reasoning_effort_levels"],
_supports_preserve_thinking = flags["supports_preserve_thinking"],
)
build = LlamaCppBackend._request_reasoning_kwargs.__get__(shim)
return build(enable_thinking, reasoning_effort, None) or {}
def _flags():
from core.inference.llama_cpp import detect_reasoning_flags
return detect_reasoning_flags(DEEPSEEK_V4_TEMPLATE, "unsloth/DeepSeek-V4-Flash")
def test_none_state_renders_non_thinking():
"""UI 'None' -> enable_thinking=false -> closed </think>, no preamble."""
kwargs = _kwargs_for(_flags(), enable_thinking = False, reasoning_effort = None)
assert kwargs == {"enable_thinking": False}
out = _render(DEEPSEEK_V4_TEMPLATE, messages = [{"role": "user", "content": "hi"}], **kwargs)
assert out.endswith("</think>")
assert "Absolute maximum" not in out
def test_high_state_renders_plain_thinking():
"""UI 'High' -> et=true, effort=high -> open <think>, no max preamble."""
kwargs = _kwargs_for(_flags(), enable_thinking = True, reasoning_effort = "high")
assert kwargs == {"enable_thinking": True, "reasoning_effort": "high"}
out = _render(DEEPSEEK_V4_TEMPLATE, messages = [{"role": "user", "content": "hi"}], **kwargs)
assert out.endswith("<think>")
assert "Absolute maximum" not in out
def test_max_state_injects_max_preamble():
"""UI 'Max' -> et=true, effort=max -> open <think> plus the max preamble."""
kwargs = _kwargs_for(_flags(), enable_thinking = True, reasoning_effort = "max")
assert kwargs == {"enable_thinking": True, "reasoning_effort": "max"}
out = _render(DEEPSEEK_V4_TEMPLATE, messages = [{"role": "user", "content": "hi"}], **kwargs)
assert out.endswith("<think>")
assert "Absolute maximum" in out
def test_high_effort_alone_enables_thinking():
"""API caller sending only reasoning_effort='high' (no enable_thinking) still
gets thinking on, so the newly exposed High mode renders correctly."""
kwargs = _kwargs_for(_flags(), enable_thinking = None, reasoning_effort = "high")
assert kwargs == {"enable_thinking": True, "reasoning_effort": "high"}
out = _render(DEEPSEEK_V4_TEMPLATE, messages = [{"role": "user", "content": "hi"}], **kwargs)
assert out.endswith("<think>")
assert "Absolute maximum" not in out

View file

@ -0,0 +1,365 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""The RAG embedding model must pass the malware/pickle gate before it is persisted or
loaded. A flagged repo (or any repo saved with force) previously reached
SentenceTransformer unscanned, bypassing the normal model-load protections."""
from pathlib import Path
import sys
import types as _types
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routes.settings as settings
class _Decision:
def __init__(self, blocked):
self.blocked = blocked
def _security_stub(blocked):
mod = _types.ModuleType("utils.security")
mod.evaluate_file_security = lambda *a, **k: _Decision(blocked)
mod.security_load_subdirs = lambda *a, **k: ()
return mod
@pytest.fixture
def client(monkeypatch):
# The settings scan unions in the ST module dirs read from modules.json; keep it
# offline and deterministic for the endpoint tests that use this fixture.
import core.rag.embeddings as embeddings
monkeypatch.setattr(embeddings, "_st_module_subdirs", lambda name, token = None: ())
saved: dict = {}
monkeypatch.setattr(settings, "default_embedding_model", lambda: "unsloth/default-embed")
monkeypatch.setattr(settings, "validate_embedding_model", lambda v: v)
monkeypatch.setattr(settings, "set_rag_embedding_model", lambda v: saved.setdefault("model", v))
monkeypatch.setattr(settings, "_llama_backend_active", lambda: False)
monkeypatch.setattr(settings, "_resolves_as_local_gguf", lambda m: False)
monkeypatch.setattr(settings, "get_rag_embedding_model", lambda: saved.get("model", ""))
monkeypatch.setattr(settings, "get_stored_embedding_model", lambda: saved.get("model"))
app = FastAPI()
app.include_router(settings.router)
app.dependency_overrides[settings.get_current_subject] = lambda: "admin"
return TestClient(app, raise_server_exceptions = False), saved
def test_flagged_repo_is_blocked_even_with_force(client, monkeypatch):
c, saved = client
monkeypatch.setitem(sys.modules, "utils.security", _security_stub(blocked = True))
r = c.put(
"/embedding-model", json = {"embedding_model": "attacker/malicious-embed", "force": True}
)
# 403, not the forceable 409, so the client does not offer "save anyway".
assert r.status_code == 403
assert "model" not in saved # force must not persist a flagged repo
def test_flagged_repo_is_blocked_without_force(client, monkeypatch):
c, saved = client
monkeypatch.setitem(sys.modules, "utils.security", _security_stub(blocked = True))
r = c.put("/embedding-model", json = {"embedding_model": "attacker/malicious-embed"})
assert r.status_code == 403
assert "model" not in saved
def test_hard_block_uses_non_forceable_status(client, monkeypatch):
# The forceable verification path uses 409; the hard security block must be distinct
# (403) so the frontend never routes it into the "save anyway" force flow.
c, _saved = client
monkeypatch.setitem(sys.modules, "utils.security", _security_stub(blocked = True))
blocked = c.put("/embedding-model", json = {"embedding_model": "attacker/malicious-embed"})
assert blocked.status_code == 403
# A verification failure (not-an-embedding-model) stays forceable at 409.
monkeypatch.setitem(sys.modules, "utils.security", _security_stub(blocked = False))
monkeypatch.setattr(settings, "is_embedding_model", lambda *a, **k: False, raising = False)
import utils.models as _models
monkeypatch.setattr(_models, "is_embedding_model", lambda *a, **k: False)
unverified = c.put("/embedding-model", json = {"embedding_model": "acme/not-an-embedder"})
assert unverified.status_code == 409
def test_llama_backend_skips_the_st_pickle_scan(monkeypatch):
# On the llama-server backend the embedder loads GGUF (inert), not the ST repo's
# pickle, so a flagged ST repo with a clean GGUF companion must not be rejected here.
saved: dict = {}
monkeypatch.setattr(settings, "default_embedding_model", lambda: "unsloth/default-embed")
monkeypatch.setattr(settings, "validate_embedding_model", lambda v: v)
monkeypatch.setattr(settings, "set_rag_embedding_model", lambda v: saved.setdefault("model", v))
monkeypatch.setattr(settings, "_llama_backend_active", lambda: True)
monkeypatch.setattr(settings, "_resolves_as_local_gguf", lambda m: False)
monkeypatch.setattr(settings, "get_rag_embedding_model", lambda: saved.get("model", ""))
monkeypatch.setattr(settings, "get_stored_embedding_model", lambda: saved.get("model"))
# force skips the GGUF availability checks; the ST pickle gate is what we assert is skipped.
called = {"scanned": False}
mod = _types.ModuleType("utils.security")
def _fail(*a, **k):
called["scanned"] = True
return _Decision(True)
mod.evaluate_file_security = _fail
mod.security_load_subdirs = lambda *a, **k: ()
monkeypatch.setitem(sys.modules, "utils.security", mod)
app = FastAPI()
app.include_router(settings.router)
app.dependency_overrides[settings.get_current_subject] = lambda: "admin"
c = TestClient(app, raise_server_exceptions = False)
r = c.put(
"/embedding-model",
json = {"embedding_model": "attacker/flagged-st-clean-gguf", "force": True},
)
assert r.status_code == 200
assert called["scanned"] is False # the ST pickle scan never ran on the llama path
assert saved.get("model") == "attacker/flagged-st-clean-gguf"
def test_runtime_llama_fallback_skips_the_st_pickle_scan(monkeypatch):
# auto resolves to sentence-transformers (GPU present) but the embedder fell back to
# llama-server at runtime (torch/CUDA load or encode failure), so the process now loads
# only inert GGUF. The real _llama_backend_active() must reflect that cached fallback,
# so a flagged ST repo with a clean GGUF companion must not be hard-blocked here.
import core.rag.embeddings as embeddings
from core.rag.embed_llama_server import LlamaServerBackend
# Simulate the runtime fallback: the process-wide backend is a LlamaServerBackend even
# though the auto resolver would still say sentence-transformers.
monkeypatch.setattr(embeddings, "_backend", LlamaServerBackend())
monkeypatch.setattr(embeddings, "_resolve_auto", lambda: "sentence-transformers")
monkeypatch.setattr(embeddings, "_st_module_subdirs", lambda name, token = None: ())
saved: dict = {}
monkeypatch.setattr(settings, "default_embedding_model", lambda: "unsloth/default-embed")
monkeypatch.setattr(settings, "validate_embedding_model", lambda v: v)
monkeypatch.setattr(settings, "set_rag_embedding_model", lambda v: saved.setdefault("model", v))
# Deliberately do NOT monkeypatch settings._llama_backend_active: this test exercises the
# real delegation to embeddings.active_backend_is_llama() so the cached fallback is honored.
monkeypatch.setattr(settings, "_resolves_as_local_gguf", lambda m: False)
monkeypatch.setattr(settings, "get_rag_embedding_model", lambda: saved.get("model", ""))
monkeypatch.setattr(settings, "get_stored_embedding_model", lambda: saved.get("model"))
called = {"scanned": False}
mod = _types.ModuleType("utils.security")
def _fail(*a, **k):
called["scanned"] = True
return _Decision(True)
mod.evaluate_file_security = _fail
mod.security_load_subdirs = lambda *a, **k: ()
monkeypatch.setitem(sys.modules, "utils.security", mod)
app = FastAPI()
app.include_router(settings.router)
app.dependency_overrides[settings.get_current_subject] = lambda: "admin"
c = TestClient(app, raise_server_exceptions = False)
r = c.put(
"/embedding-model",
json = {"embedding_model": "attacker/flagged-st-clean-gguf", "force": True},
)
assert r.status_code == 200
assert called["scanned"] is False # the ST pickle scan never ran on the llama fallback
assert saved.get("model") == "attacker/flagged-st-clean-gguf"
def test_active_backend_is_llama_reflects_cache_and_resolver(monkeypatch):
# active_backend_is_llama() reports the ACTUAL built backend when one exists, and defers
# to the resolver (fresh-process behavior) when none has been built yet.
import core.rag.embeddings as embeddings
import core.rag.config as rag_config
from core.rag.embed_llama_server import LlamaServerBackend
# A cached llama backend wins even when auto would resolve to sentence-transformers.
monkeypatch.setattr(rag_config, "EMBED_BACKEND", "auto")
monkeypatch.setattr(embeddings, "_resolve_auto", lambda: "sentence-transformers")
monkeypatch.setattr(embeddings, "_backend", LlamaServerBackend())
assert embeddings.active_backend_is_llama() is True
# A cached ST backend reports False even when the resolver now picks llama, so its
# pickle stays gated (the cached backend, not the resolver, is what actually embeds).
monkeypatch.setattr(embeddings, "_resolve_auto", lambda: "llama-server")
monkeypatch.setattr(embeddings, "_backend", embeddings._SentenceTransformersBackend())
assert embeddings.active_backend_is_llama() is False
# No cached backend -> the resolver decides, unchanged from before.
monkeypatch.setattr(embeddings, "_resolve_auto", lambda: "sentence-transformers")
monkeypatch.setattr(embeddings, "_backend", None)
assert embeddings.active_backend_is_llama() is False # auto -> sentence-transformers
monkeypatch.setattr(embeddings, "_resolve_auto", lambda: "llama-server")
assert embeddings.active_backend_is_llama() is True # auto -> llama-server
# An explicit (non-auto) key is honored verbatim without a cached backend.
monkeypatch.setattr(rag_config, "EMBED_BACKEND", "llama-server")
assert embeddings.active_backend_is_llama() is True
def test_settings_scan_scopes_module_subdirs(monkeypatch):
# The settings scan must pass the ST module dirs (0_Transformer/) as load roots so a
# pickle directly under one blocks; assert those subdirs reach evaluate_file_security.
saved: dict = {}
monkeypatch.setattr(settings, "default_embedding_model", lambda: "unsloth/default-embed")
monkeypatch.setattr(settings, "validate_embedding_model", lambda v: v)
monkeypatch.setattr(settings, "set_rag_embedding_model", lambda v: saved.setdefault("model", v))
monkeypatch.setattr(settings, "_llama_backend_active", lambda: False)
monkeypatch.setattr(settings, "_resolves_as_local_gguf", lambda m: False)
monkeypatch.setattr(settings, "get_rag_embedding_model", lambda: saved.get("model", ""))
monkeypatch.setattr(settings, "get_stored_embedding_model", lambda: saved.get("model"))
import core.rag.embeddings as embeddings
monkeypatch.setattr(
embeddings, "_st_module_subdirs", lambda name, token = None: ("0_Transformer",)
)
seen = {}
def _capture(*a, **k):
seen["subdirs"] = tuple(k.get("load_subdirs") or ())
return _Decision(False)
mod = _types.ModuleType("utils.security")
mod.security_load_subdirs = lambda *a, **k: ()
mod.evaluate_file_security = _capture
monkeypatch.setitem(sys.modules, "utils.security", mod)
app = FastAPI()
app.include_router(settings.router)
app.dependency_overrides[settings.get_current_subject] = lambda: "admin"
c = TestClient(app, raise_server_exceptions = False)
r = c.put(
"/embedding-model", json = {"embedding_model": "acme/embed-with-module-dir", "force": True}
)
assert r.status_code == 200
assert "0_Transformer" in seen["subdirs"]
def test_clean_repo_saves_under_force(client, monkeypatch):
c, saved = client
monkeypatch.setitem(sys.modules, "utils.security", _security_stub(blocked = False))
r = c.put("/embedding-model", json = {"embedding_model": "acme/clean-embed", "force": True})
assert r.status_code == 200
assert saved.get("model") == "acme/clean-embed"
def test_load_sink_refuses_flagged_model(monkeypatch):
monkeypatch.setitem(sys.modules, "utils.security", _security_stub(blocked = True))
import core.rag.embeddings as embeddings
with pytest.raises(embeddings.UnsafeEmbeddingModelError):
embeddings._guard_model_security("attacker/malicious-embed")
def test_load_sink_allows_clean_model(monkeypatch):
monkeypatch.setitem(sys.modules, "utils.security", _security_stub(blocked = False))
import core.rag.embeddings as embeddings
embeddings._guard_model_security("acme/clean-embed") # no raise
def test_sink_threads_ambient_token_into_scan(monkeypatch):
# A gated repo set via env/default has no request token; the guard must feed the
# loader's own token to the scan, or it fails open for the repo that still loads.
seen = {}
mod = _types.ModuleType("utils.security")
mod.security_load_subdirs = (
lambda name, token = None: seen.setdefault("subdirs_token", token) or ()
)
mod.evaluate_file_security = lambda *a, **k: seen.setdefault(
"scan_token", k.get("hf_token")
) or _Decision(False)
monkeypatch.setitem(sys.modules, "utils.security", mod)
import core.rag.embeddings as embeddings
monkeypatch.setattr(embeddings, "_ambient_hf_token", lambda: "hf_ambient")
embeddings._guard_model_security("acme/gated-embed")
assert seen["scan_token"] == "hf_ambient"
assert seen["subdirs_token"] == "hf_ambient"
def test_sink_scopes_st_module_subdirs_into_scan(monkeypatch):
# A flagged pickle directly under a Transformer module dir (0_Transformer/) must
# reach the scan as a load root; assert the guard unions the module dirs into
# load_subdirs so evaluate_file_security treats such a pickle as root-level.
seen = {}
def _capture(*a, **k):
seen["subdirs"] = tuple(k.get("load_subdirs") or ())
return _Decision(False)
mod = _types.ModuleType("utils.security")
mod.security_load_subdirs = lambda name, token = None: ()
mod.evaluate_file_security = _capture
monkeypatch.setitem(sys.modules, "utils.security", mod)
import core.rag.embeddings as embeddings
monkeypatch.setattr(embeddings, "_ambient_hf_token", lambda: None)
monkeypatch.setattr(
embeddings, "_st_module_subdirs", lambda name, token = None: ("0_Transformer",)
)
embeddings._guard_model_security("acme/embed-with-module-dir")
assert "0_Transformer" in seen["subdirs"]
def test_st_module_subdirs_reads_local_modules_json(tmp_path, monkeypatch):
# The helper must parse each module's non-empty "path" from a local repo's
# modules.json and drop the root-level ("") Transformer entry.
import json
import core.rag.embeddings as embeddings
(tmp_path / "modules.json").write_text(
json.dumps(
[
{"idx": 0, "name": "0", "path": "0_Transformer", "type": "..."},
{"idx": 1, "name": "1", "path": "1_Pooling", "type": "..."},
{"idx": 2, "name": "2", "path": "", "type": "..."},
]
)
)
subdirs = embeddings._st_module_subdirs(str(tmp_path), None)
assert subdirs == ("0_Transformer", "1_Pooling")
def test_st_module_subdirs_swallows_errors(monkeypatch):
# Any failure (no modules.json, offline, malformed) returns () so the guard never
# bricks the embedder.
import huggingface_hub
import core.rag.embeddings as embeddings
def _boom(*a, **k):
raise RuntimeError("offline")
monkeypatch.setattr(huggingface_hub, "hf_hub_download", _boom)
assert embeddings._st_module_subdirs("acme/no-such-repo-xyz", None) == ()
def test_security_block_is_not_swallowed_by_llama_fallback(monkeypatch):
# The ST encode fallback must re-raise a security block, not swap to llama-server.
import core.rag.embeddings as embeddings
def _boom(*a, **k):
raise embeddings.UnsafeEmbeddingModelError("flagged")
monkeypatch.setattr(embeddings, "_st_encode", _boom)
monkeypatch.setattr(
embeddings,
"_switch_to_llama_fallback",
lambda err: pytest.fail("security block must not fall back to llama-server"),
)
with pytest.raises(embeddings.UnsafeEmbeddingModelError):
embeddings._SentenceTransformersBackend().encode(["hi"])

View file

@ -1,15 +1,8 @@
# 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.
"""
"""Gemma-native tool-call parsing edge cases: commas inside bare string values,
and markers inside another call's argument data staying data."""
from __future__ import annotations
@ -21,7 +14,11 @@ _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
from core.inference.tool_call_parser import (
_gemma_parse_value,
parse_tool_calls_from_text,
)
from core.tool_healing import strip_tool_call_markup
def _args(call: dict) -> dict:
@ -40,14 +37,22 @@ def test_bare_string_argument_with_comma_is_kept():
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_empty_bare_value_becomes_empty_string_not_dropped():
# An empty bare value (``{query:}``) must serialise as ``""`` (``{"query":}`` is invalid JSON and dropped the call).
calls = parse_tool_calls_from_text("<|tool_call>call:search{query:,unit:celsius}<tool_call|>")
assert len(calls) == 1, calls
assert _args(calls[0]) == {"query": "", "unit": "celsius"}
only = parse_tool_calls_from_text("<|tool_call>call:get{q:}<tool_call|>")
assert len(only) == 1, only
assert _args(only[0]) == {"q": ""}
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.
# A comma before digits-then-colon (timestamp/ratio) is value text, not a key.
calls = parse_tool_calls_from_text(
"<|tool_call>call:remind{query:meet at 10:00, 11:00 tomorrow,priority:high}<tool_call|>"
)
@ -55,9 +60,16 @@ def test_bare_value_with_timestamps_after_comma_is_kept():
assert _args(calls[0]) == {"query": "meet at 10:00, 11:00 tomorrow", "priority": "high"}
def test_wrapperless_bare_value_with_timestamps_after_comma_is_kept():
# The wrapper-less Gemma form (no <|tool_call> markers) goes through the
# _gemma_parse_stripped_body scanner and its _GEMMA_KEY_RE.
calls = parse_tool_calls_from_text("call:web_search{query:meet at 10:00, 11:00 tomorrow}")
assert len(calls) == 1, calls
assert calls[0]["function"]["name"] == "web_search"
assert _args(calls[0]) == {"query": "meet at 10:00, 11:00 tomorrow"}
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>'
@ -75,8 +87,6 @@ def test_two_separate_gemma_calls_both_parse():
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>'
@ -86,8 +96,6 @@ def test_mixed_format_calls_preserve_document_order():
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>)'
@ -98,18 +106,14 @@ def test_json_marker_inside_gemma_argument_is_not_a_second_call():
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.
# An UNQUOTED Gemma value containing a literal marker: the marker is nested in the outer
# candidate span, so it must not be promoted to a standalone `terminal` call (no 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"]}
@ -123,8 +127,6 @@ def test_array_keeps_numbers_and_quoted_elements():
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|>"
)
@ -138,9 +140,6 @@ def test_nested_array_elements_are_normalised():
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|>"
@ -159,3 +158,254 @@ def test_json_marker_inside_xml_parameter_is_not_a_second_call():
)
calls = parse_tool_calls_from_text(content)
assert [c["function"]["name"] for c in calls] == ["python"], calls
def test_unclosed_think_literal_inside_tool_argument_does_not_hide_later_call():
# A literal <think> inside a completed call's arguments is argument data; both calls must parse.
text = '[TOOL_CALLS]a{"x":"literal <think> marker"} b[ARGS]{"y":2}'
calls = parse_tool_calls_from_text(text)
assert [c["function"]["name"] for c in calls] == ["a", "b"], calls
def test_real_think_block_with_rehearsal_inside_still_skips_only_the_rehearsal():
# A genuine reasoning block still hides its rehearsal while a real call after it parses.
text = '<think>web_search[ARGS]{"q":"draft"}</think>real[ARGS]{"q":"go"}'
calls = parse_tool_calls_from_text(text)
assert [c["function"]["name"] for c in calls] == ["real"], calls
def test_wrapperless_nested_object_argument_is_parsed():
# skip_special_tokens stream: wrapper and <|"|> markers stripped, so a nested object arrives bare.
calls = parse_tool_calls_from_text("call:f{loc:{city:NYC},n:3}")
assert len(calls) == 1
assert _args(calls[0]) == {"loc": {"city": "NYC"}, "n": 3}
def test_wrapperless_array_argument_is_parsed():
calls = parse_tool_calls_from_text("call:label{labels:[bug,ui],n:2}")
assert len(calls) == 1
assert _args(calls[0]) == {"labels": ["bug", "ui"], "n": 2}
def test_wrapperless_deeply_nested_object_and_array_are_preserved():
# The single-pass parser must keep multi-level nesting (objects inside
# objects, arrays inside arrays) intact, not flatten or drop it.
calls = parse_tool_calls_from_text(
"call:f{loc:{city:NYC,geo:{lat:1,lng:2}},tags:[a,b,[c,d]],n:3}"
)
assert len(calls) == 1
assert _args(calls[0]) == {
"loc": {"city": "NYC", "geo": {"lat": 1, "lng": 2}},
"tags": ["a", "b", ["c", "d"]],
"n": 3,
}
def test_gemma_parse_array_advances_on_stray_brace():
# Regression: a stray '}' / ']' / ',' where an array element is expected must
# not stall _gemma_parse_value at the same index (it looped forever before).
from core.inference.tool_call_parser import _gemma_parse_array
items, end, closed = _gemma_parse_array("[a,}]", 0)
assert end == 5 and closed is True # consumed through the closing ']'
assert items[0] == "a"
def test_gemma_close_marker_inside_quoted_arg_is_not_leaked_when_stripping():
# Parse keeps the quoted close marker as data; strip removes the whole span.
text = '<|tool_call>call:python{code:<|"|>print("<tool_call|>")<|"|>}<tool_call|>'
calls = parse_tool_calls_from_text(text)
assert len(calls) == 1, calls
assert _args(calls[0]) == {"code": 'print("<tool_call|>")'}
assert strip_tool_call_markup("before " + text + " after") == "before after"
assert strip_tool_call_markup("before " + text + " after", final = True) == "before after"
def test_nested_xml_in_malformed_gemma_call_does_not_execute():
# The failed Gemma candidate's span still covers its nested <function=>.
text = (
"<|tool_call>call:outer{code:<function=terminal><parameter=command>id"
"</parameter></function></tool_call>, broken:{x}}<tool_call|>"
)
for allow_incomplete in (True, False):
calls = parse_tool_calls_from_text(text, allow_incomplete = allow_incomplete)
assert "terminal" not in [c["function"]["name"] for c in calls], calls
def test_unbalanced_gemma_call_with_xml_does_not_execute():
# Unclosed braces cover to EOF, so the trailing <function=> is excluded.
text = (
"<|tool_call>call:outer{code:<function=terminal>"
"<parameter=command>id</parameter></function>"
)
for allow_incomplete in (True, False):
calls = parse_tool_calls_from_text(text, allow_incomplete = allow_incomplete)
assert "terminal" not in [c["function"]["name"] for c in calls], calls
def test_standalone_function_xml_still_parses():
text = "<function=terminal><parameter=command>id</parameter></function>"
calls = parse_tool_calls_from_text(text)
assert [c["function"]["name"] for c in calls] == ["terminal"], calls
def test_xml_between_braces_and_close_marker_does_not_execute():
# Coverage runs to the close marker, so <function=> in the gap is data.
text = (
"<|tool_call>call:outer{broken:{x}}<function=terminal>"
"<parameter=command>id</parameter></function><tool_call|>"
)
for allow_incomplete in (True, False):
calls = parse_tool_calls_from_text(text, allow_incomplete = allow_incomplete)
assert "terminal" not in [c["function"]["name"] for c in calls], calls
def test_balanced_inner_call_inside_unclosed_outer_does_not_execute():
text = "<|tool_call>call:outer{code:<|tool_call>call:terminal{command:id}<tool_call|>"
for allow_incomplete in (True, False):
calls = parse_tool_calls_from_text(text, allow_incomplete = allow_incomplete)
assert "terminal" not in [c["function"]["name"] for c in calls], calls
def test_strip_preserves_text_after_malformed_gemma_close():
# Junk before the close is a malformed span: strip through it, keep the tail.
text = "pre <|tool_call>call:t{a:1} note <tool_call|> post"
assert strip_tool_call_markup(text) == "pre post"
assert strip_tool_call_markup(text, final = True) == "pre post"
def test_malformed_closed_gemma_span_is_stripped():
assert (
strip_tool_call_markup('before <|tool_call>{"name":"x"}<tool_call|> after')
== "before after"
)
def test_valid_call_after_missing_close_is_recovered():
# A close-less call covers only its braces, so the later call is recovered.
text = "<|tool_call>call:a{x:1} <|tool_call>call:b{y:2}<tool_call|>"
names_inc = [
c["function"]["name"] for c in parse_tool_calls_from_text(text, allow_incomplete = True)
]
assert "b" in names_inc, names_inc
names_strict = [
c["function"]["name"] for c in parse_tool_calls_from_text(text, allow_incomplete = False)
]
assert names_strict == ["b"], names_strict
def test_strip_non_final_keeps_incomplete_gemma_block():
text = "before <|tool_call>call:t{"
assert strip_tool_call_markup(text) == text
assert strip_tool_call_markup(text, final = True) == "before"
def test_json_call_between_gemma_braces_and_close_does_not_execute():
# A JSON call between the outer's braces and its close is covered data.
text = (
"<|tool_call>call:outer{broken:{x}}"
'<tool_call>{"name":"terminal","arguments":{"command":"id"}}</tool_call>'
"<tool_call|>"
)
for allow_incomplete in (True, False):
calls = parse_tool_calls_from_text(text, allow_incomplete = allow_incomplete)
assert "terminal" not in [c["function"]["name"] for c in calls], calls
def test_gemma_call_between_gemma_braces_and_close_does_not_execute():
# Same escape with a Gemma-native inner marker.
text = "<|tool_call>call:outer{broken:{x}}<|tool_call>call:terminal{command:id}<tool_call|><tool_call|>"
for allow_incomplete in (True, False):
calls = parse_tool_calls_from_text(text, allow_incomplete = allow_incomplete)
assert "terminal" not in [c["function"]["name"] for c in calls], calls
def test_strip_final_keeps_text_after_closed_xml_with_inner_gemma_opener():
# The to-EOF Gemma sweep must not eat visible text after </function>.
text = (
'before <function=python><parameter=code>print("<|tool_call>")</parameter></function> after'
)
assert strip_tool_call_markup(text, final = True) == "before after"
assert strip_tool_call_markup(text) == "before after"
def test_strip_final_keeps_text_after_closed_block_with_call_form_gemma_opener():
# A call-form Gemma opener quoted in a closed block must not truncate it.
xml = "<function=python><parameter=code><|tool_call>call:t{</parameter></function>"
json_block = (
'<tool_call>{"name":"python","arguments":{"code":"<|tool_call>call:t{"}}</tool_call>'
)
for block in (xml, json_block):
text = "before " + block + " after"
assert strip_tool_call_markup(text, final = True) == "before after", block
assert strip_tool_call_markup(text) == "before after", block
def test_function_sibling_after_close_less_gemma_marker_is_recovered():
# The close-less marker covers only its braces; the XML sibling is recovered.
text = (
"<|tool_call>call:bad{broken:{x}} "
"<function=terminal><parameter=command>id</parameter></function>"
)
for allow_incomplete in (True, False):
calls = parse_tool_calls_from_text(text, allow_incomplete = allow_incomplete)
assert [c["function"]["name"] for c in calls] == ["terminal"], calls
def test_valid_call_after_close_less_marker_with_quoted_close_token_is_recovered():
# A close token quoted in the later call must not extend the earlier
# close-less marker's coverage over that call.
gemma = '<|tool_call>call:a{x:1} <|tool_call>call:b{note:<|"|></tool_call><|"|>}<tool_call|>'
names = [
c["function"]["name"] for c in parse_tool_calls_from_text(gemma, allow_incomplete = False)
]
assert names == ["b"], names
json_text = (
'<tool_call>{"name":"a","arguments":{}} '
'<tool_call>{"name":"b","arguments":{"x":"</tool_call>"}}</tool_call>'
)
names_j = [
c["function"]["name"] for c in parse_tool_calls_from_text(json_text, allow_incomplete = False)
]
assert "b" in names_j, names_j
def test_gemma_parse_value_always_advances_on_stray_delimiter():
# A stray delimiter (`,`, `}`, `]`) at the primitive position must still advance the
# index by at least one, or a caller looping on it spins forever at 100% CPU (DoS).
for delim in (",", "}", "]"):
text = delim + "rest"
value, nxt, _explicit = _gemma_parse_value(text, 0)
assert nxt > 0, (delim, value, nxt)
def test_malformed_gemma_array_does_not_hang():
# ``[},]`` puts a stray ``}`` at the primitive position inside a list body.
# On the buggy parser this hangs the server; guard with a wall-clock timeout
# so the regression fails loudly instead of blocking CI forever.
import threading
result: dict = {}
def _run():
result["calls"] = parse_tool_calls_from_text("<|tool_call>call:f{a:[},]}<tool_call|>")
t = threading.Thread(target = _run, daemon = True)
t.start()
t.join(timeout = 10.0)
assert not t.is_alive(), "parse_tool_calls_from_text hung on malformed array input"
def test_malformed_gemma_mapping_value_does_not_hang():
# A stray ``}`` where a mapping value is expected must also terminate.
import threading
result: dict = {}
def _run():
result["calls"] = parse_tool_calls_from_text("<|tool_call>call:f{a:}},b:1}<tool_call|>")
t = threading.Thread(target = _run, daemon = True)
t.start()
t.join(timeout = 10.0)
assert not t.is_alive(), "parse_tool_calls_from_text hung on malformed mapping input"

View file

@ -1,18 +1,16 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Unit tests for utils.hf_xet_fallback: the no-progress watchdog, the Xet->HTTP
transport policy, and the HF_HUB_DISABLE_XET precondition the fallback rests on.
CPU-only, no network, no real subprocess (the per-attempt download seam is
monkeypatched).
"""Tests for the Studio shim over the shared unsloth_zoo Xet -> HTTP fallback.
The transport-policy matrix is tested once in unsloth_zoo; here we assert only the
Studio seam: re-exporting the shared API and injecting the marker-aware
prepare_cache_for_transport on the HTTP retry. CPU-only, no network, no real subprocess.
"""
from __future__ import annotations
import subprocess
import sys
import threading
import time
import types as _types
from pathlib import Path
@ -22,9 +20,8 @@ _BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# Stub heavy/unavailable deps before importing the module under test. Use the
# real structlog when present; a bare stub left in sys.modules would break later
# modules that log at import time.
# Stub heavy/unavailable deps before importing the module under test. Use real structlog when present;
# a bare stub would break later modules that log at import time.
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
@ -34,171 +31,59 @@ except ImportError:
sys.modules["structlog"] = _types.ModuleType("structlog")
import huggingface_hub
from huggingface_hub import constants as hf_constants
try:
import unsloth_zoo.hf_xet_fallback as _shared_mod
shared = _shared_mod
except Exception: # noqa: BLE001 - still collect degraded-path tests when unsloth_zoo is unavailable
shared = None
import utils.hf_xet_fallback as xf
# --------------------------------------------------------------------------- #
# Watchdog: fires only on a constant-size .incomplete, sparse-aware byte total.
# --------------------------------------------------------------------------- #
REPO = "ztest/xet-watchdog"
@pytest.fixture
def hf_cache(tmp_path, monkeypatch):
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
return tmp_path
def _blobs_dir(root: Path, repo_id: str = REPO) -> Path:
d = root / f"models--{repo_id.replace('/', '--')}" / "blobs"
d.mkdir(parents = True, exist_ok = True)
return d
def _wait(
predicate,
timeout: float = 2.0,
step: float = 0.02,
) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(step)
return predicate()
def test_constant_incomplete_fires_stall(hf_cache):
blobs = _blobs_dir(hf_cache)
(blobs / "deadbeef.incomplete").write_bytes(b"\0" * 1024) # never grows
calls: list[str] = []
stop = xf.start_watchdog(
repo_ids = [REPO], on_stall = calls.append, interval = 0.05, stall_timeout = 0.3
)
try:
assert _wait(
lambda: len(calls) >= 1, timeout = 3.0
), "watchdog never fired on a constant-size .incomplete"
finally:
stop.set()
assert "stalled" in calls[0].lower()
def test_growing_incomplete_never_stalls(hf_cache):
blobs = _blobs_dir(hf_cache)
part = blobs / "growing.incomplete"
part.write_bytes(b"\0" * 1024)
grow_stop = threading.Event()
def _grow():
size = 1024
while not grow_stop.wait(0.05):
size += 4096
part.write_bytes(b"\0" * size)
grower = threading.Thread(target = _grow, daemon = True)
grower.start()
calls: list[str] = []
stop = xf.start_watchdog(
repo_ids = [REPO], on_stall = calls.append, interval = 0.05, stall_timeout = 0.3
)
try:
time.sleep(1.0) # well past stall_timeout, but bytes keep growing
assert calls == [], "watchdog fired despite continuous progress"
finally:
stop.set()
grow_stop.set()
def test_no_incomplete_never_stalls(hf_cache):
blobs = _blobs_dir(hf_cache)
(blobs / "finalized_blob").write_bytes(b"\0" * 4096) # no .incomplete
calls: list[str] = []
stop = xf.start_watchdog(
repo_ids = [REPO], on_stall = calls.append, interval = 0.05, stall_timeout = 0.3
)
try:
time.sleep(0.8)
assert calls == [], "watchdog fired with no active .incomplete"
finally:
stop.set()
def test_stall_fires_at_most_once(hf_cache):
blobs = _blobs_dir(hf_cache)
(blobs / "frozen.incomplete").write_bytes(b"\0" * 2048)
calls: list[str] = []
stop = xf.start_watchdog(
repo_ids = [REPO], on_stall = calls.append, interval = 0.05, stall_timeout = 0.2
)
try:
assert _wait(lambda: len(calls) >= 1, timeout = 3.0)
time.sleep(0.6) # keep ticking; must not fire again
assert len(calls) == 1, f"on_stall fired {len(calls)} times, expected exactly 1"
finally:
stop.set()
def test_get_state_empty_cache(hf_cache):
assert xf.get_hf_download_state([REPO]) == (0, False)
def test_get_state_absent_cache_root(tmp_path, monkeypatch):
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path / "no-such-cache"))
assert xf.get_hf_download_state([REPO]) == (0, False)
def test_get_state_skips_local_paths(hf_cache):
# Filesystem paths are not HF repo IDs and must be ignored without error.
assert xf.get_hf_download_state(["/abs/path", "./rel", "~user", "c:\\x"]) == (0, False)
def test_get_state_sparse_aware(hf_cache):
blobs = _blobs_dir(hf_cache)
sparse = blobs / "sparse.incomplete"
with open(sparse, "wb") as f:
f.truncate(64 * 1024 * 1024) # large apparent size, few allocated blocks
st = sparse.stat()
if getattr(st, "st_blocks", 0) == 0:
pytest.skip("filesystem does not report st_blocks; sparse accounting unavailable")
total, has_incomplete = xf.get_hf_download_state([REPO])
assert has_incomplete is True
assert total < st.st_size, "sparse partial counted at apparent size, not allocated blocks"
# --------------------------------------------------------------------------- #
# Transport policy: cached short-circuit, cancel, error propagation, and the
# single Xet->HTTP fallback. _run_download_attempt is faked, so no real spawn.
# --------------------------------------------------------------------------- #
DL_REPO, FILE = "ztest/xet-dl", "model-Q4_K_XL.gguf"
@pytest.fixture(autouse = True)
def _no_real_cache_hit(monkeypatch):
"""Default: the cached probe misses; tests override it to force a hit."""
def _requires_shared():
if shared is None:
pytest.skip("unsloth_zoo.hf_xet_fallback is not installed in this environment")
def test_shim_reexports_shared_api():
_requires_shared()
assert xf.DownloadStallError is shared.DownloadStallError
for name in (
"start_watchdog",
"get_hf_download_state",
"child_should_disable_xet",
"hf_hub_download_with_xet_fallback",
"snapshot_download_with_xet_fallback",
):
assert hasattr(xf, name), f"shim missing {name}"
def test_child_should_disable_xet_truth_table():
assert xf.child_should_disable_xet({"disable_xet": True}) is True
assert xf.child_should_disable_xet({"disable_xet": False}) is False
assert xf.child_should_disable_xet({}) is False
def test_shim_injects_studio_prepare_on_http_retry(monkeypatch):
"""A Xet stall retries over HTTP and the shim runs Studio's marker-aware
``prepare_cache_for_transport(..., 'http')`` before the retry."""
_requires_shared()
for var in ("UNSLOTH_DISABLE_XET", "UNSLOTH_STABLE_DOWNLOADS", "HF_HUB_DISABLE_XET"):
monkeypatch.delenv(var, raising = False)
monkeypatch.setattr(huggingface_hub, "try_to_load_from_cache", lambda *a, **k: None)
seen_disable_xet = []
class _FakeAttempt:
"""Records calls to the download seam and returns scripted results."""
def __init__(self, results):
self._results = list(results)
self.calls = []
def __call__(
self,
def fake_attempt(
repo_id,
filename,
token,
*,
kind,
params,
token,
repo_type,
disable_xet,
cancel_event,
@ -208,146 +93,277 @@ class _FakeAttempt:
on_status,
force_download = False,
):
self.calls.append(
_types.SimpleNamespace(
repo_id = repo_id,
filename = filename,
disable_xet = disable_xet,
repo_type = repo_type,
)
)
return self._results[len(self.calls) - 1]
seen_disable_xet.append(disable_xet)
return ("ok", "/cache/model.gguf") if disable_xet else ("stall", None)
monkeypatch.setattr(shared, "_run_download_attempt", fake_attempt)
def _install(monkeypatch, results):
fake = _FakeAttempt(results)
monkeypatch.setattr(xf, "_run_download_attempt", fake)
return fake
def test_cached_file_short_circuits(monkeypatch, tmp_path):
cached = tmp_path / "cached.gguf"
cached.write_bytes(b"\0" * 8)
monkeypatch.setattr(huggingface_hub, "try_to_load_from_cache", lambda *a, **k: str(cached))
fake = _install(monkeypatch, []) # must not be called
out = xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
assert out == str(cached)
assert fake.calls == [], "spawned a download for an already-cached file"
def test_cancel_before_start_raises_no_attempt(monkeypatch):
fake = _install(monkeypatch, [])
ev = threading.Event()
ev.set()
with pytest.raises(RuntimeError, match = "Cancelled"):
xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None, cancel_event = ev)
assert fake.calls == []
def test_nonstall_error_propagates_without_fallback(monkeypatch):
fake = _install(monkeypatch, [("error", "RepositoryNotFoundError: 404 not found")])
with pytest.raises(RuntimeError, match = "RepositoryNotFoundError"):
xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
assert len(fake.calls) == 1, "deterministic error must not trigger an HTTP fallback"
assert fake.calls[0].disable_xet is False
def test_immediate_success_uses_xet_only(monkeypatch):
prepared = []
monkeypatch.setattr(
"hub.utils.download_registry.prepare_cache_for_transport",
lambda *a, **k: prepared.append(a),
)
fake = _install(monkeypatch, [("ok", "/cache/model.gguf")])
out = xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
assert out == "/cache/model.gguf"
assert len(fake.calls) == 1 and fake.calls[0].disable_xet is False
assert prepared == [], "no cache prep should run when Xet succeeds first try"
def test_stall_then_http_fallback_succeeds(monkeypatch):
prepared = []
monkeypatch.setattr(
"hub.utils.download_registry.prepare_cache_for_transport",
lambda repo_type, repo_id, mode, *a, **k: prepared.append((repo_type, repo_id, mode)),
)
fake = _install(monkeypatch, [("stall", None), ("ok", "/cache/model.gguf")])
out = xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
assert out == "/cache/model.gguf"
assert len(fake.calls) == 2
assert fake.calls[0].disable_xet is False # Xet first
assert fake.calls[1].disable_xet is True # HTTP fallback
assert prepared == [("model", DL_REPO, "http")], "must prep cache for HTTP before the retry"
assert seen_disable_xet == [False, True] # Xet first, then HTTP
assert prepared == [("model", DL_REPO, "http")], "shim must run Studio's marker-aware prep"
def test_second_stall_raises_download_stall_error(monkeypatch):
monkeypatch.setattr(
"hub.utils.download_registry.prepare_cache_for_transport", lambda *a, **k: None
)
fake = _install(monkeypatch, [("stall", None), ("stall", None)])
with pytest.raises(xf.DownloadStallError):
xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
assert len(fake.calls) == 2
def test_shim_snapshot_injects_studio_prepare(monkeypatch):
"""The snapshot wrapper forwards Studio's marker-aware prep, like the file wrapper."""
captured = {}
def fake_snapshot(repo_id, **kwargs):
captured["repo_id"] = repo_id
captured["prepare_for_http_fn"] = kwargs.get("prepare_for_http_fn")
return "/tmp/snap-dir"
monkeypatch.setattr(xf, "_shared_snapshot_download_with_xet_fallback", fake_snapshot)
out = xf.snapshot_download_with_xet_fallback("org/model")
assert out == "/tmp/snap-dir"
assert captured["repo_id"] == "org/model"
assert captured["prepare_for_http_fn"] is xf._studio_prepare_for_http
def test_cancelled_midattempt_raises_no_fallback(monkeypatch):
fake = _install(monkeypatch, [("cancelled", None)])
with pytest.raises(RuntimeError, match = "Cancelled"):
xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
assert len(fake.calls) == 1
def test_degrades_gracefully_without_shared_helper(monkeypatch):
"""On an older unsloth_zoo lacking the shared helper, the shim still imports (Studio
boots) and exposes stub API doing plain HF downloads with the watchdog disabled."""
import importlib
class _BlockShared:
def find_spec(
self,
name,
path = None,
target = None,
):
if name == "unsloth_zoo.hf_xet_fallback":
raise ModuleNotFoundError(f"No module named '{name}'", name = name)
return None
finder = _BlockShared()
saved_shared = sys.modules.pop("unsloth_zoo.hf_xet_fallback", None)
saved_shim = sys.modules.pop("utils.hf_xet_fallback", None)
sys.meta_path.insert(0, finder)
try:
degraded = importlib.import_module("utils.hf_xet_fallback")
# Boots without raising and mirrors the shared API surface.
assert issubclass(degraded.DownloadStallError, RuntimeError)
assert degraded.child_should_disable_xet({"disable_xet": True}) is True
assert degraded.get_hf_download_state(["x"]) is None # unmeasurable
event = degraded.start_watchdog(repo_ids = ["x"], on_stall = lambda m: None)
assert hasattr(event, "set") and not event.is_set() # never fires
# Degraded mode still emits heartbeats so the inactivity deadline is not tripped.
import time as _time
beats = []
hb_stop = degraded.start_watchdog(
repo_ids = ["x"],
on_stall = lambda m: None,
on_heartbeat = beats.append,
interval = 0.02,
)
try:
deadline = _time.monotonic() + 2.0
while not beats and _time.monotonic() < deadline:
_time.sleep(0.02)
assert beats, "degraded watchdog emitted no heartbeat"
finally:
hb_stop.set()
# Downloads fall back to plain huggingface_hub (no watchdog, no crash).
called = {}
def _fake_snapshot(repo_id, **kwargs):
called["repo_id"] = repo_id
return "/snap-dir"
monkeypatch.setattr(huggingface_hub, "snapshot_download", _fake_snapshot)
assert degraded.snapshot_download_with_xet_fallback("org/model") == "/snap-dir"
assert called["repo_id"] == "org/model"
# Cancellation still holds: an already-set cancel_event aborts before the HF download.
import threading as _threading
cancelled = _threading.Event()
cancelled.set()
called.clear()
with pytest.raises(RuntimeError, match = "Cancelled"):
degraded.snapshot_download_with_xet_fallback("org/model", cancel_event = cancelled)
assert "repo_id" not in called, "degraded download ran despite cancellation"
finally:
sys.meta_path.remove(finder)
sys.modules.pop("utils.hf_xet_fallback", None)
if saved_shared is not None:
sys.modules["unsloth_zoo.hf_xet_fallback"] = saved_shared
if saved_shim is not None:
sys.modules["utils.hf_xet_fallback"] = saved_shim
def test_per_file_independent_fallback(monkeypatch):
"""A stalled shard falls back; a sibling shard that succeeds does not."""
monkeypatch.setattr(
"hub.utils.download_registry.prepare_cache_for_transport", lambda *a, **k: None
)
fake = _install(monkeypatch, [("ok", "/a"), ("stall", None), ("ok", "/b")])
assert xf.hf_hub_download_with_xet_fallback(DL_REPO, "shardA.gguf", None) == "/a"
assert xf.hf_hub_download_with_xet_fallback(DL_REPO, "shardB.gguf", None) == "/b"
assert [c.disable_xet for c in fake.calls] == [False, False, True]
def test_degrades_when_unsloth_zoo_entirely_absent():
"""When unsloth_zoo is absent entirely, the import raises
ModuleNotFoundError(name='unsloth_zoo') (top-level package). Guard that the shim still
degrades and does not re-raise, breaking every Studio import that pulls it in."""
import importlib
class _BlockZoo:
def find_spec(
self,
name,
path = None,
target = None,
):
# Whole package absent, so ModuleNotFoundError.name is the top-level 'unsloth_zoo'.
if name == "unsloth_zoo" or name.startswith("unsloth_zoo."):
raise ModuleNotFoundError("No module named 'unsloth_zoo'", name = "unsloth_zoo")
return None
finder = _BlockZoo()
saved = {
k: v
for k, v in list(sys.modules.items())
if k == "unsloth_zoo" or k.startswith("unsloth_zoo.")
}
for k in saved:
del sys.modules[k]
saved_shim = sys.modules.pop("utils.hf_xet_fallback", None)
sys.meta_path.insert(0, finder)
try:
degraded = importlib.import_module("utils.hf_xet_fallback")
# Boots without raising and exposes the stub API.
assert issubclass(degraded.DownloadStallError, RuntimeError)
assert degraded.get_hf_download_state(["x"]) is None
event = degraded.start_watchdog(repo_ids = ["x"], on_stall = lambda m: None)
assert hasattr(event, "set") and not event.is_set()
finally:
sys.meta_path.remove(finder)
sys.modules.pop("utils.hf_xet_fallback", None)
sys.modules.update(saved)
if saved_shim is not None:
sys.modules["utils.hf_xet_fallback"] = saved_shim
# --------------------------------------------------------------------------- #
# Precondition: HF_HUB_DISABLE_XET is read at import time, so assert its effect
# in a FRESH interpreter (huggingface/huggingface_hub#3266 once ignored it).
# --------------------------------------------------------------------------- #
def _safe_path() -> str:
def test_degrades_when_shared_helper_import_raises_importerror():
"""unsloth_zoo can be installed yet fail to import when torch is missing (llama.cpp/GGUF-only
Studio), raising ImportError not ModuleNotFoundError. The shim must degrade for that too."""
import importlib
class _BlockWithImportError:
def find_spec(
self,
name,
path = None,
target = None,
):
if name == "unsloth_zoo.hf_xet_fallback":
# Mirror a torch-less install: a plain ImportError with no .name.
raise ImportError("Unsloth: Pytorch is not installed.")
return None
finder = _BlockWithImportError()
saved_shared = sys.modules.pop("unsloth_zoo.hf_xet_fallback", None)
saved_zoo = sys.modules.pop("unsloth_zoo", None)
saved_shim = sys.modules.pop("utils.hf_xet_fallback", None)
sys.meta_path.insert(0, finder)
try:
degraded = importlib.import_module("utils.hf_xet_fallback")
assert issubclass(degraded.DownloadStallError, RuntimeError)
assert degraded.get_hf_download_state(["x"]) is None
event = degraded.start_watchdog(repo_ids = ["x"], on_stall = lambda m: None)
assert hasattr(event, "set") and not event.is_set()
finally:
sys.meta_path.remove(finder)
sys.modules.pop("utils.hf_xet_fallback", None)
if saved_shared is not None:
sys.modules["unsloth_zoo.hf_xet_fallback"] = saved_shared
if saved_zoo is not None:
sys.modules["unsloth_zoo"] = saved_zoo
if saved_shim is not None:
sys.modules["utils.hf_xet_fallback"] = saved_shim
def test_retries_under_light_gpu_init_when_import_fails(monkeypatch):
"""GPU detection in unsloth_zoo's __init__ raises NotImplementedError on a GPU-less host. The shim
retries under UNSLOTH_ZOO_DISABLE_GPU_INIT=1, restores the env, and degrades if the retry fails.
The backend loads lazily (first use of a heavy helper), so this triggers the load explicitly
before asserting the retry/degrade behavior."""
import importlib
import os
return os.environ.get("PATH", "")
monkeypatch.delenv("UNSLOTH_ZOO_DISABLE_GPU_INIT", raising = False)
seen_env = []
class _GpuGatedBlocker:
def find_spec(
self,
name,
path = None,
target = None,
):
# Crash is in unsloth_zoo's __init__, so intercept "unsloth_zoo" itself (the parent).
if name == "unsloth_zoo":
# Record the env each attempt sees; raise the no-GPU error both times so the shim
# degrades.
seen_env.append(os.environ.get("UNSLOTH_ZOO_DISABLE_GPU_INIT"))
raise NotImplementedError("Unsloth cannot find any torch accelerator")
return None
finder = _GpuGatedBlocker()
saved = {
k: v
for k, v in list(sys.modules.items())
if k == "unsloth_zoo" or k.startswith("unsloth_zoo.")
}
for k in saved:
del sys.modules[k]
saved_shim = sys.modules.pop("utils.hf_xet_fallback", None)
sys.meta_path.insert(0, finder)
try:
degraded = importlib.import_module("utils.hf_xet_fallback")
# Import is light (lazy backend); unsloth_zoo not loaded yet.
assert seen_env == [], seen_env
# First use of a heavy helper triggers the load (attempt without the light env, then a retry
# with it set); accessing DownloadStallError drives it via __getattr__.
stall_error = degraded.DownloadStallError
assert seen_env == [None, "1"], seen_env
# Both attempts raised -> Studio still boots in degraded mode.
assert issubclass(stall_error, RuntimeError)
# The env override must not leak past the load.
assert os.environ.get("UNSLOTH_ZOO_DISABLE_GPU_INIT") is None
finally:
sys.meta_path.remove(finder)
sys.modules.pop("utils.hf_xet_fallback", None)
sys.modules.update(saved)
if saved_shim is not None:
sys.modules["utils.hf_xet_fallback"] = saved_shim
def test_disable_xet_constant_set_in_fresh_interpreter():
code = (
"from huggingface_hub import constants as c; "
"import sys; sys.exit(0 if c.HF_HUB_DISABLE_XET is True else 17)"
)
proc = subprocess.run(
[sys.executable, "-c", code],
env = {"HF_HUB_DISABLE_XET": "1", "PATH": _safe_path()},
capture_output = True,
text = True,
)
assert proc.returncode == 0, (
f"HF_HUB_DISABLE_XET=1 did not set constants.HF_HUB_DISABLE_XET=True "
f"(rc={proc.returncode}): {proc.stderr}"
)
def test_importing_child_should_disable_xet_stays_light(monkeypatch):
"""Regression guard for the stale-transformers-sidecar bug: importing the shim (and
``child_should_disable_xet``) must NOT pull in ``transformers``/``unsloth_zoo``. The worker calls
this at startup to decide the Xet env flip BEFORE activating the sidecar; an eager import here
would cache the default transformers 4.57.x in sys.modules, defeating the sidecar sys.path prepend
and breaking 5.x models (Qwen3.5/GLM/gemma-4)."""
import importlib
for name in [
m
for m in list(sys.modules)
if m == "transformers"
or m.startswith("transformers.")
or m == "unsloth_zoo"
or m.startswith("unsloth_zoo.")
or m == "utils.hf_xet_fallback"
]:
monkeypatch.delitem(sys.modules, name, raising = False)
def test_default_leaves_xet_enabled():
code = (
"from huggingface_hub import constants as c; "
"import sys; sys.exit(0 if c.HF_HUB_DISABLE_XET is False else 17)"
)
proc = subprocess.run(
[sys.executable, "-c", code],
env = {"PATH": _safe_path()}, # no HF_HUB_DISABLE_XET
capture_output = True,
text = True,
)
assert proc.returncode == 0, (
f"without the env var, constants.HF_HUB_DISABLE_XET was not False "
f"(rc={proc.returncode}): {proc.stderr}"
)
mod = importlib.import_module("utils.hf_xet_fallback")
# The lightweight decision works without the heavy backend.
assert mod.child_should_disable_xet({"disable_xet": True}) is True
assert mod.child_should_disable_xet({}) is False
# And nothing heavy was imported as a side effect.
assert "transformers" not in sys.modules, "importing the shim must not import transformers"
assert "unsloth_zoo" not in sys.modules, "importing the shim must not import unsloth_zoo"

View file

@ -0,0 +1,42 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Default Chat model metadata must not block on remote Hugging Face discovery."""
from __future__ import annotations
import sys
import time
from pathlib import Path
_BACKEND = Path(__file__).resolve().parent.parent
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
from core.inference.orchestrator import InferenceOrchestrator # noqa: E402
def test_default_models_returns_static_defaults_before_top_fetch(monkeypatch):
sleep_seconds = 2.0
def _slow_fetch(self: InferenceOrchestrator) -> None:
time.sleep(sleep_seconds)
self._top_gguf_cache = ["unsloth/slow-GGUF"]
self._top_models_ready.set()
monkeypatch.setattr(InferenceOrchestrator, "_fetch_top_models", _slow_fetch)
orchestrator = InferenceOrchestrator()
started = time.monotonic()
defaults = orchestrator.default_models
elapsed = time.monotonic() - started
assert elapsed < 0.5, f"default_models blocked for {elapsed:.2f}s"
assert defaults == orchestrator._static_models
assert "unsloth/slow-GGUF" not in defaults
deadline = time.monotonic() + sleep_seconds + 5
while not orchestrator._top_models_ready.is_set() and time.monotonic() < deadline:
time.sleep(0.05)
assert "unsloth/slow-GGUF" in orchestrator.default_models

View file

@ -112,7 +112,7 @@ def test_route_llama_streaming_async_clients_disable_proxy_env():
continue
calls.append(node)
assert len(calls) == 4
assert len(calls) == 5
for call in calls:
assert any(
kw.arg == "trust_env" and isinstance(kw.value, ast.Constant) and kw.value.value is False

View file

@ -1,7 +1,8 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""install_llama_prebuilt.py: host->repo mapping and the --resolve-prebuilt mode.
"""install_llama_prebuilt.py: the --resolve-prebuilt probe (plans against the fork
by default; --published-repo overrides).
These back the in-app update for source-build (markerless) installs: the backend
asks the installer whether an official prebuilt exists for this host without
@ -24,9 +25,7 @@ if str(_studio) not in sys.path:
ilp = importlib.import_module("install_llama_prebuilt")
if not hasattr(ilp, "published_repo_for_host") or not hasattr(
ilp, "resolve_simple_install_release_plans"
):
if not hasattr(ilp, "resolve_simple_install_release_plans"):
pytest.skip("PR symbols not present - check branch", allow_module_level = True)
FORK = ilp.DEFAULT_PUBLISHED_REPO # unslothai/llama.cpp
@ -56,71 +55,25 @@ def _host(**kw):
return ilp.HostInfo(**base)
def test_published_repo_for_host():
# CPU-only Linux (x64 and arm64) -> ggml-org upstream.
assert ilp.published_repo_for_host(_host(is_linux = True, is_x86_64 = True)) == UPSTREAM
assert (
ilp.published_repo_for_host(_host(is_linux = True, is_arm64 = True, machine = "aarch64"))
== UPSTREAM
)
# GPU Linux -> fork.
assert (
ilp.published_repo_for_host(_host(is_linux = True, is_x86_64 = True, has_usable_nvidia = True))
== FORK
)
assert ilp.published_repo_for_host(_host(is_linux = True, is_x86_64 = True, has_rocm = True)) == FORK
# CPU-only Windows -> ggml-org (setup.ps1: the fork ships no win-cpu bundle).
assert (
ilp.published_repo_for_host(_host(system = "Windows", is_windows = True, is_x86_64 = True))
== UPSTREAM
)
# GPU Windows -> fork.
assert (
ilp.published_repo_for_host(
_host(system = "Windows", is_windows = True, is_x86_64 = True, has_usable_nvidia = True)
)
== FORK
)
# macOS -> fork regardless of GPU (ggml-org macOS bundles need too-new macOS).
assert (
ilp.published_repo_for_host(
_host(system = "Darwin", is_macos = True, is_arm64 = True, machine = "arm64")
)
== FORK
)
# Linux with AMD tooling but no probed GPU -> fork (setup.sh routes on tooling).
assert (
ilp.published_repo_for_host(
_host(is_linux = True, is_x86_64 = True), linux_amd_tooling_present = True
)
== FORK
)
# The tooling hint is Linux-only: Windows CPU stays on ggml-org.
assert (
ilp.published_repo_for_host(
_host(system = "Windows", is_windows = True, is_x86_64 = True),
linux_amd_tooling_present = True,
)
== UPSTREAM
)
def test_macos_intel_and_arm_both_route_to_fork():
# macOS uses the unslothai fork's own Mac prebuilts for BOTH arm64 and Intel;
# there is no longer any upstream-on-macOS default path, so the obsolete
# pre-macOS-26 pin (b9415) is gone.
assert (
ilp.published_repo_for_host(
_host(system = "Darwin", is_macos = True, is_arm64 = True, machine = "arm64")
)
== FORK
)
assert (
ilp.published_repo_for_host(
_host(system = "Darwin", is_macos = True, is_x86_64 = True, machine = "x86_64")
)
== FORK
def test_force_cpu_clears_all_gpu_attributes_including_intel():
# --cpu-fallback is the "select the CPU prebuilt even when a GPU is present"
# escape hatch. It must drop EVERY GPU attribute, including has_intel_gpu, or
# the planner still prepends the Vulkan asset on an Intel-GPU host.
host = _host(
is_linux = True,
is_x86_64 = True,
has_usable_nvidia = True,
has_physical_nvidia = True,
has_rocm = True,
rocm_gfx_target = "gfx1100",
has_intel_gpu = True,
)
forced = ilp._apply_host_overrides(host, force_cpu = True)
assert forced.has_usable_nvidia is False
assert forced.has_physical_nvidia is False
assert forced.has_rocm is False
assert forced.rocm_gfx_target is None
assert forced.has_intel_gpu is False
def test_macos_upstream_pin_only_for_explicit_pre26_upstream():
@ -188,15 +141,13 @@ def test_resolve_prebuilt_unavailable(monkeypatch, capsys):
assert out["repo"] == FORK
def test_resolve_prebuilt_linux_amd_tooling_routes_to_fork(monkeypatch, capsys):
# CPU-probed Linux host but rocminfo on PATH: the dispatch must route to the
# fork so a HIP source build is not offered an upstream CPU prebuilt.
monkeypatch.setattr(ilp, "detect_host", lambda: _host(is_linux = True, is_x86_64 = True))
monkeypatch.setattr(ilp.shutil, "which", lambda tool: tool == "rocminfo")
def _run_resolve_capture_host(monkeypatch, capsys):
"""Drive --resolve-prebuilt and return the host the resolver was handed."""
seen = {}
def _resolver(tag, host, repo, published_release_tag):
seen["repo"] = repo
seen["host"] = host
raise ilp.PrebuiltFallback("no asset")
monkeypatch.setattr(ilp, "resolve_simple_install_release_plans", _resolver)
@ -207,10 +158,33 @@ def test_resolve_prebuilt_linux_amd_tooling_routes_to_fork(monkeypatch, capsys):
)
assert ilp.main() == ilp.EXIT_SUCCESS
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
return seen, out
def test_resolve_prebuilt_cpu_linux_routes_to_fork(monkeypatch, capsys):
# CPU-only Linux host (no GPU): the dispatch routes to the fork, which now
# ships the CPU prebuilt -- it no longer falls back to ggml-org upstream.
monkeypatch.setattr(ilp, "detect_host", lambda: _host(is_linux = True, is_x86_64 = True))
seen, out = _run_resolve_capture_host(monkeypatch, capsys)
assert seen["repo"] == FORK
assert out["repo"] == FORK
def test_resolve_prebuilt_rocm_sdk_only_host_still_offered_cpu(monkeypatch, capsys):
# A CPU-only host that merely has ROCm/HIP SDK tools on PATH (no AMD GPU, so
# detect_host leaves has_rocm False) is a valid CPU-prebuilt target. The probe
# must NOT reclassify it as ROCm from tool presence alone and suppress the CPU
# bundle -- that would deny the fork CPU prebuilt to a legitimate CPU source
# build. The host is left CPU-only and resolves against the fork.
monkeypatch.setattr(ilp, "detect_host", lambda: _host(is_linux = True, is_x86_64 = True))
monkeypatch.setattr(
ilp.shutil, "which", lambda tool: "/opt/rocm/bin/hipconfig" if tool == "hipconfig" else None
)
seen, out = _run_resolve_capture_host(monkeypatch, capsys)
assert seen["repo"] == FORK
assert seen["host"].has_rocm is False
# 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.
@ -360,3 +334,386 @@ def test_sm103_host_drops_cuda128_windows_build():
)
kept_b200 = ilp._drop_blackwell_incapable_windows_cuda(b200, [cuda128, cuda129])
assert [a.name for a in kept_b200] == [cuda128.name, cuda129.name]
def _upstream_release(tag, asset_names):
return {
"tag_name": tag,
"assets": [
{"name": n, "browser_download_url": f"https://example/{n}"} for n in asset_names
],
}
def test_direct_upstream_arm64_intel_prefers_vulkan():
# Auto-detected Intel GPU on Linux arm64 -> Vulkan prebuilt first, CPU
# second (mirrors the x86_64 branch; ggml-org ships the arm64 Vulkan asset).
host = _host(is_linux = True, is_arm64 = True, machine = "aarch64", has_intel_gpu = True)
rel = _upstream_release(
"b9925",
["llama-b9925-bin-ubuntu-vulkan-arm64.tar.gz", "llama-b9925-bin-ubuntu-arm64.tar.gz"],
)
plan = ilp.direct_upstream_release_plan(rel, host, UPSTREAM, "latest")
kinds = [a.install_kind for a in plan.attempts]
assert kinds[0] == "linux-vulkan", kinds
assert "linux-arm64" in kinds
assert plan.attempts[0].name == "llama-b9925-bin-ubuntu-vulkan-arm64.tar.gz"
def test_direct_upstream_intel_with_hidden_nvidia_is_cpu_only():
# A host with a physical NVIDIA hidden via CUDA_VISIBLE_DEVICES (physical
# True, usable False) + an Intel iGPU must NOT get the Vulkan archive even
# when planning directly against upstream: Vulkan ignores CUDA_VISIBLE_DEVICES
# and could grab the reserved card. It falls through to the CPU asset.
host = _host(
is_linux = True,
is_x86_64 = True,
has_intel_gpu = True,
has_physical_nvidia = True,
has_usable_nvidia = False,
)
rel = _upstream_release(
"b9925",
["llama-b9925-bin-ubuntu-vulkan-x64.tar.gz", "llama-b9925-bin-ubuntu-x64.tar.gz"],
)
plan = ilp.direct_upstream_release_plan(rel, host, UPSTREAM, "latest")
assert [a.install_kind for a in plan.attempts] == ["linux-cpu"]
def test_direct_upstream_arm64_without_intel_is_cpu_only():
host = _host(is_linux = True, is_arm64 = True, machine = "aarch64")
rel = _upstream_release(
"b9925",
["llama-b9925-bin-ubuntu-vulkan-arm64.tar.gz", "llama-b9925-bin-ubuntu-arm64.tar.gz"],
)
plan = ilp.direct_upstream_release_plan(rel, host, UPSTREAM, "latest")
assert [a.install_kind for a in plan.attempts] == ["linux-arm64"]
def test_direct_upstream_x86_intel_prefers_vulkan():
host = _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True)
rel = _upstream_release(
"b9925",
["llama-b9925-bin-ubuntu-vulkan-x64.tar.gz", "llama-b9925-bin-ubuntu-x64.tar.gz"],
)
plan = ilp.direct_upstream_release_plan(rel, host, UPSTREAM, "latest")
kinds = [a.install_kind for a in plan.attempts]
assert kinds[0] == "linux-vulkan", kinds
assert "linux-cpu" in kinds
def test_linux_vulkan_health_glob_matches_bare_cpu_lib():
# The widened glob must cover both arch-suffixed (x64) and bare (arm64) CPU
# libs so a valid Vulkan install is not re-flagged unhealthy every check.
choice = ilp.AssetChoice(
repo = UPSTREAM,
tag = "b9925",
name = "llama-b9925-bin-ubuntu-vulkan-arm64.tar.gz",
url = "https://example/x",
source_label = "upstream",
install_kind = "linux-vulkan",
)
groups = ilp.runtime_payload_health_groups(choice)
assert ["libggml-cpu*.so*"] in groups
assert ["libggml-cpu-*.so*"] not in groups
def test_route_to_vulkan_prebuilt_auto_intel_goes_upstream_and_drops_fork_pin():
# Routing fork -> upstream also drops the fork release pin, which is in a
# different tag namespace and would make the upstream resolver miss.
host = _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True)
routed, repo, tag = ilp._route_to_vulkan_prebuilt(host, FORK, "b9596-mix-abc", force_cpu = False)
assert repo == UPSTREAM
assert tag == ""
assert routed.has_intel_gpu is True
def test_route_to_vulkan_prebuilt_preserves_explicit_upstream_pin():
# A pin set WITH an explicit upstream repo is already on upstream -> kept.
host = _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True)
_routed, repo, tag = ilp._route_to_vulkan_prebuilt(host, UPSTREAM, "b9596", force_cpu = False)
assert repo == UPSTREAM
assert tag == "b9596"
def test_route_to_vulkan_prebuilt_cpu_fallback_wins():
# --cpu-fallback suppresses Vulkan routing even for an Intel host.
host = _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True)
routed, repo, tag = ilp._route_to_vulkan_prebuilt(host, FORK, "b9596-mix-abc", force_cpu = True)
assert repo == FORK
assert tag == "b9596-mix-abc"
assert routed is host
def test_route_to_vulkan_prebuilt_hidden_nvidia_not_rerouted():
# A mixed NVIDIA+Intel host that hid NVIDIA (CUDA_VISIBLE_DEVICES=""/-1):
# physical NVIDIA present but not usable. Must NOT auto-route to Vulkan, or
# Vulkan (which ignores CUDA_VISIBLE_DEVICES) could grab the reserved GPU.
host = _host(
is_linux = True,
is_x86_64 = True,
has_intel_gpu = True,
has_physical_nvidia = True,
has_usable_nvidia = False,
)
_routed, repo, _tag = ilp._route_to_vulkan_prebuilt(host, FORK, "", force_cpu = False)
assert repo == FORK
def test_route_to_vulkan_prebuilt_rocm_host_not_rerouted():
# An Intel iGPU alongside a usable ROCm GPU stays on its ROCm/fork path.
host = _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True, has_rocm = True)
_routed, repo, _tag = ilp._route_to_vulkan_prebuilt(host, FORK, "", force_cpu = False)
assert repo == FORK
def test_route_to_vulkan_prebuilt_non_intel_unchanged():
host = _host(is_linux = True, is_x86_64 = True)
routed, repo, _tag = ilp._route_to_vulkan_prebuilt(host, FORK, "", force_cpu = False)
assert repo == FORK
assert routed is host
def test_resolve_prebuilt_intel_host_routes_to_upstream(monkeypatch, capsys):
# The --resolve-prebuilt probe must agree with the install path: an
# auto-detected Intel host resolves against upstream (Vulkan), not the fork.
monkeypatch.setattr(
ilp, "detect_host", lambda: _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True)
)
seen, out = _run_resolve_capture_host(monkeypatch, capsys)
assert seen["repo"] == UPSTREAM
assert out["repo"] == UPSTREAM
# ---------------------------------------------------------------------------
# windows_intel_gpu_in_registry: the in-process Windows Intel probe. A fake
# winreg module stands in for the real registry so the walk runs anywhere.
# ---------------------------------------------------------------------------
class _FakeRegKey:
def __init__(
self,
subkeys = None,
values = None,
denied = False,
):
self.subkeys = subkeys or {}
self.values = values or {}
self.denied = denied
def __enter__(self):
return self
def __exit__(self, *exc):
return False
class _FakeWinreg:
HKEY_LOCAL_MACHINE = object()
def __init__(self, root_key):
self._root_key = root_key
def OpenKey(self, parent, name):
if parent is self.HKEY_LOCAL_MACHINE:
# Pin the production constant: a typo'd class GUID must fail here,
# not silently return the fake tree.
if name != ilp._WINDOWS_DISPLAY_CLASS_KEY:
raise FileNotFoundError(name)
if self._root_key is None:
raise FileNotFoundError(name)
return self._root_key
key = parent.subkeys.get(name)
if key is None:
# Real winreg raises OSError, never KeyError, for a missing key.
raise FileNotFoundError(name)
if key.denied:
raise PermissionError(name)
return key
def QueryInfoKey(self, key):
return (len(key.subkeys), len(key.values), 0)
def EnumKey(self, key, index):
return list(key.subkeys)[index]
def QueryValueEx(self, key, value_name):
if value_name not in key.values:
raise FileNotFoundError(value_name)
return (key.values[value_name], 1)
def _probe_with_display_class(monkeypatch, adapters):
# The helper lazily does `import winreg`; plant the fake in sys.modules the
# same way unsloth_cli/tests/test_start.py fakes it for _refresh_windows_path.
monkeypatch.setitem(sys.modules, "winreg", _FakeWinreg(_FakeRegKey(subkeys = adapters)))
return ilp.windows_intel_gpu_in_registry()
def test_windows_intel_registry_matches_vendor_id(monkeypatch):
assert (
_probe_with_display_class(
monkeypatch,
{
"0000": _FakeRegKey(
values = {
"MatchingDeviceId": r"PCI\VEN_8086&DEV_56A0&SUBSYS_12345678",
"DriverDesc": "Intel(R) Arc(TM) A770 Graphics",
}
),
},
)
is True
)
def test_windows_intel_registry_matches_driver_desc_without_device_id(monkeypatch):
assert (
_probe_with_display_class(
monkeypatch,
{
"0000": _FakeRegKey(values = {"DriverDesc": "Intel(R) UHD Graphics 630"}),
},
)
is True
)
def test_windows_intel_registry_ignores_non_intel_adapters(monkeypatch):
assert (
_probe_with_display_class(
monkeypatch,
{
"0000": _FakeRegKey(
values = {
"MatchingDeviceId": r"PCI\VEN_10DE&DEV_2684",
"DriverDesc": "NVIDIA GeForce RTX 4090",
}
),
"0001": _FakeRegKey(
values = {
"MatchingDeviceId": r"PCI\VEN_1002&DEV_744C",
"DriverDesc": "AMD Radeon RX 7900 XTX",
}
),
},
)
is False
)
def test_windows_intel_registry_skips_restricted_properties_subkey(monkeypatch):
# The real class key carries an ACL-restricted "Properties" subkey and can
# deny access to individual adapter keys; neither may abort the walk.
assert (
_probe_with_display_class(
monkeypatch,
{
"Properties": _FakeRegKey(denied = True),
"0000": _FakeRegKey(denied = True),
"0001": _FakeRegKey(
values = {
"MatchingDeviceId": r"PCI\VEN_8086&DEV_56A0",
}
),
},
)
is True
)
def test_windows_intel_registry_missing_class_key_is_false(monkeypatch):
monkeypatch.setitem(sys.modules, "winreg", _FakeWinreg(None))
assert ilp.windows_intel_gpu_in_registry() is False
def _detect_windows_host(
monkeypatch,
winreg_fake,
powershell_stdout = "",
):
"""Drive the real detect_host() as a GPU-less Windows host with a fake
registry, recording every run_capture invocation. Pins the wiring the
unit tests above cannot see: registry-first, CIM only on a registry miss."""
monkeypatch.setitem(sys.modules, "winreg", winreg_fake)
monkeypatch.setattr(ilp.platform, "system", lambda: "Windows")
monkeypatch.setattr(ilp.platform, "machine", lambda: "AMD64")
for _env in (
"CUDA_VISIBLE_DEVICES",
"HIP_VISIBLE_DEVICES",
"ROCR_VISIBLE_DEVICES",
"HIP_PATH",
"ROCM_PATH",
):
monkeypatch.delenv(_env, raising = False)
monkeypatch.setattr(
ilp.shutil,
"which",
lambda name: "powershell" if name in ("powershell", "pwsh") else None,
)
captured = []
def _fake_run_capture(command, **kwargs):
captured.append(command[0])
if command[0] == "powershell":
return SimpleNamespace(returncode = 0, stdout = powershell_stdout, stderr = "")
return SimpleNamespace(returncode = 1, stdout = "", stderr = "")
monkeypatch.setattr(ilp, "run_capture", _fake_run_capture)
return ilp.detect_host(), captured
def test_detect_host_registry_intel_skips_cim_probe(monkeypatch):
winreg = _FakeWinreg(
_FakeRegKey(
subkeys = {
"0000": _FakeRegKey(values = {"MatchingDeviceId": r"PCI\VEN_8086&DEV_56A0"}),
}
)
)
host, captured = _detect_windows_host(monkeypatch, winreg)
assert host.has_intel_gpu is True
assert "powershell" not in captured
def test_detect_host_cim_fallback_fires_on_registry_miss(monkeypatch):
winreg = _FakeWinreg(
_FakeRegKey(
subkeys = {
"0000": _FakeRegKey(values = {"MatchingDeviceId": r"PCI\VEN_10DE&DEV_2684"}),
}
)
)
host, captured = _detect_windows_host(
monkeypatch, winreg, powershell_stdout = "Intel(R) Arc(TM) A770 Graphics"
)
assert host.has_intel_gpu is True
assert "powershell" in captured
def test_windows_intel_registry_unexpected_error_is_false(monkeypatch):
# The probe is advisory: even a non-OSError bug in the walk must return
# False (deferring to the CIM fallback), never crash detect_host.
class _ExplodingWinreg:
HKEY_LOCAL_MACHINE = object()
def OpenKey(self, parent, name):
raise TypeError(name)
monkeypatch.setitem(sys.modules, "winreg", _ExplodingWinreg())
assert ilp.windows_intel_gpu_in_registry() is False
def test_detect_host_cim_rescues_exploding_registry(monkeypatch):
class _ExplodingWinreg:
HKEY_LOCAL_MACHINE = object()
def OpenKey(self, parent, name):
raise TypeError(name)
host, captured = _detect_windows_host(
monkeypatch, _ExplodingWinreg(), powershell_stdout = "Intel(R) Arc(TM) A770 Graphics"
)
assert host.has_intel_gpu is True
assert "powershell" in captured

View file

@ -0,0 +1,320 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
import asyncio
import os
import sys
import threading
import pytest
_backend = os.path.join(os.path.dirname(__file__), "..")
sys.path.insert(0, _backend)
from core.inference import llama_admission
from core.inference.llama_admission import (
ADMISSION_CONTROL_ENV,
ADMISSION_KEEPALIVE_INTERVAL_ENV,
ADMISSION_MAX_QUEUE_ENV,
ADMISSION_QUEUE_TIMEOUT_ENV,
DEFAULT_ADMISSION_KEEPALIVE_INTERVAL_S,
DEFAULT_ADMISSION_MAX_QUEUE,
DEFAULT_ADMISSION_QUEUE_TIMEOUT_S,
LlamaAdmissionConfig,
LlamaAdmissionQueueFull,
get_llama_admission_queue,
llama_admission_config_from_env,
reset_llama_admission_queues,
)
@pytest.fixture(autouse = True)
def _reset_queues():
reset_llama_admission_queues()
yield
reset_llama_admission_queues()
def test_admission_config_defaults(monkeypatch):
for name in (
ADMISSION_CONTROL_ENV,
ADMISSION_QUEUE_TIMEOUT_ENV,
ADMISSION_KEEPALIVE_INTERVAL_ENV,
ADMISSION_MAX_QUEUE_ENV,
):
monkeypatch.delenv(name, raising = False)
config = llama_admission_config_from_env()
assert config.enabled is True
assert config.queue_timeout_s == DEFAULT_ADMISSION_QUEUE_TIMEOUT_S
assert config.keepalive_interval_s == DEFAULT_ADMISSION_KEEPALIVE_INTERVAL_S
assert config.max_queue == DEFAULT_ADMISSION_MAX_QUEUE
def test_admission_config_env_overrides(monkeypatch):
monkeypatch.setenv(ADMISSION_CONTROL_ENV, "off")
monkeypatch.setenv(ADMISSION_QUEUE_TIMEOUT_ENV, "0")
monkeypatch.setenv(ADMISSION_KEEPALIVE_INTERVAL_ENV, "0.25")
monkeypatch.setenv(ADMISSION_MAX_QUEUE_ENV, "0")
config = llama_admission_config_from_env()
assert config.enabled is False
assert config.queue_timeout_s is None
assert config.keepalive_interval_s == 0.25
assert config.max_queue is None
def test_admission_config_positive_queue_timeout_env(monkeypatch):
monkeypatch.setenv(ADMISSION_QUEUE_TIMEOUT_ENV, "600")
config = llama_admission_config_from_env()
assert config.queue_timeout_s == 600.0
def test_fifo_capacity_one_grants_next_waiter_on_release():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig()
first = queue.reserve(capacity = 1, config = config)
second = queue.reserve(capacity = 1, config = config)
third = queue.reserve(capacity = 1, config = config)
first_lease = first.lease_nowait()
assert first_lease is not None
assert second.lease_nowait() is None
assert third.lease_nowait() is None
assert queue.snapshot().queued == 2
first_lease.release()
second_lease = await second.wait(0.1)
assert second_lease is not None
assert third.lease_nowait() is None
second_lease.release()
third_lease = await third.wait(0.1)
assert third_lease is not None
third_lease.release()
snapshot = queue.snapshot()
assert snapshot.active == 0
assert snapshot.queued == 0
asyncio.run(_run())
def test_queue_full_rejects_excess_waiter():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig(max_queue = 1)
first = queue.reserve(capacity = 1, config = config)
queued = queue.reserve(capacity = 1, config = config)
assert first.lease_nowait() is not None
assert queued.lease_nowait() is None
with pytest.raises(LlamaAdmissionQueueFull):
queue.reserve(capacity = 1, config = config)
asyncio.run(_run())
def test_disabled_admission_bypasses_active_slot_limit():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig(enabled = False)
first = queue.reserve(capacity = 1, config = config)
second = queue.reserve(capacity = 1, config = config)
assert first.lease_nowait() is not None
assert second.lease_nowait() is not None
assert queue.snapshot().active == 0
assert queue.snapshot().queued == 0
asyncio.run(_run())
def test_cancelling_promoted_waiter_releases_slot():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig()
first = queue.reserve(capacity = 1, config = config)
second = queue.reserve(capacity = 1, config = config)
first_lease = first.lease_nowait()
first_lease.release()
await asyncio.sleep(0)
second.cancel()
snapshot = queue.snapshot()
assert snapshot.active == 0
assert snapshot.queued == 0
asyncio.run(_run())
def test_cancelling_promoted_waiter_before_delivery_releases_slot():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig()
first = queue.reserve(capacity = 1, config = config)
second = queue.reserve(capacity = 1, config = config)
first_lease = first.lease_nowait()
first_lease.release()
second.cancel()
snapshot = queue.snapshot()
assert snapshot.active == 0
assert snapshot.queued == 0
asyncio.run(_run())
def test_external_waiter_future_cancel_invalidates_reservation():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig()
first = queue.reserve(capacity = 1, config = config)
second = queue.reserve(capacity = 1, config = config)
first_lease = first.lease_nowait()
assert first_lease is not None
assert second._waiter is not None
second._waiter.future.cancel()
assert second.lease_nowait() is None
assert second.is_cancelled is True
assert await second.wait(0.01) is None
first_lease.release()
snapshot = queue.snapshot()
assert snapshot.active == 0
assert snapshot.queued == 0
asyncio.run(_run())
def test_wait_returns_none_when_waiter_future_cancelled_during_wait():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig()
first = queue.reserve(capacity = 1, config = config)
second = queue.reserve(capacity = 1, config = config)
first_lease = first.lease_nowait()
assert first_lease is not None
assert second._waiter is not None
wait_task = asyncio.create_task(second.wait(1.0))
await asyncio.sleep(0)
second._waiter.future.cancel()
assert await asyncio.wait_for(wait_task, timeout = 0.1) is None
assert second.is_cancelled is True
first_lease.release()
snapshot = queue.snapshot()
assert snapshot.active == 0
assert snapshot.queued == 0
asyncio.run(_run())
def test_capacity_increase_promotes_existing_waiter_fifo():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig()
first = queue.reserve(capacity = 1, config = config)
second = queue.reserve(capacity = 1, config = config)
first_lease = first.lease_nowait()
assert first_lease is not None
assert second.lease_nowait() is None
assert queue.snapshot().active == 1
assert queue.snapshot().queued == 1
third = queue.reserve(capacity = 2, config = config)
second_lease = await second.wait(0.1)
assert second_lease is not None
assert third.lease_nowait() is None
snapshot = queue.snapshot()
assert snapshot.capacity == 2
assert snapshot.active == 2
assert snapshot.queued == 1
first_lease.release()
third_lease = await third.wait(0.1)
assert third_lease is not None
second_lease.release()
third_lease.release()
snapshot = queue.snapshot()
assert snapshot.active == 0
assert snapshot.queued == 0
asyncio.run(_run())
def test_lease_release_is_idempotent_under_concurrent_calls():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig()
reservation = queue.reserve(capacity = 1, config = config)
lease = reservation.lease_nowait()
assert lease is not None
threads = [threading.Thread(target = lease.release) for _ in range(16)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
snapshot = queue.snapshot()
assert snapshot.active == 0
assert snapshot.queued == 0
asyncio.run(_run())
def test_new_key_evicts_idle_prior_load_queues():
# Each model load carries a fresh ephemeral port, so a new base_url key must
# not leave the drained queues from earlier loads accumulating forever.
get_llama_admission_queue("http://127.0.0.1:1001")
get_llama_admission_queue("http://127.0.0.1:1002")
assert set(llama_admission._QUEUES) == {"http://127.0.0.1:1002"}
get_llama_admission_queue("http://127.0.0.1:1003")
assert set(llama_admission._QUEUES) == {"http://127.0.0.1:1003"}
def test_new_key_retains_in_flight_prior_load_queue():
config = LlamaAdmissionConfig()
busy = get_llama_admission_queue("http://127.0.0.1:2001")
async def _run():
reservation = busy.reserve(capacity = 1, config = config)
lease = reservation.lease_nowait()
assert lease is not None
# A new load must not drop a queue that still has an in-flight request.
get_llama_admission_queue("http://127.0.0.1:2002")
assert set(llama_admission._QUEUES) == {"http://127.0.0.1:2001", "http://127.0.0.1:2002"}
# Once it drains, the next load reclaims it.
lease.release()
get_llama_admission_queue("http://127.0.0.1:2003")
assert set(llama_admission._QUEUES) == {"http://127.0.0.1:2003"}
asyncio.run(_run())

View file

@ -0,0 +1,53 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
import os
import sys
import pytest
_backend = os.path.join(os.path.dirname(__file__), "..")
sys.path.insert(0, _backend)
from core.inference import llama_cpp as llama_cpp_module
from core.inference.llama_cpp import LlamaCppBackend
@pytest.fixture
def backend(monkeypatch):
monkeypatch.setattr(LlamaCppBackend, "_kill_orphaned_servers", lambda self: 0)
monkeypatch.setattr(llama_cpp_module.atexit, "register", lambda *_args, **_kwargs: None)
return LlamaCppBackend()
def test_effective_parallel_slots_initial_value_is_one(backend):
assert backend.effective_parallel_slots == 1
def test_effective_parallel_slots_commit_uses_final_positive_parallel(backend):
backend._commit_effective_parallel_slots(3)
assert backend.effective_parallel_slots == 3
@pytest.mark.parametrize("value", [None, 0, -2, "not-an-int"])
def test_effective_parallel_slots_commit_invalid_value_falls_back_to_one(backend, value):
backend._commit_effective_parallel_slots(value)
assert backend.effective_parallel_slots == 1
def test_effective_parallel_slots_reset_returns_to_one(backend):
backend._commit_effective_parallel_slots(4)
backend._reset_effective_parallel_slots()
assert backend.effective_parallel_slots == 1
def test_effective_parallel_slots_unload_resets_to_one(backend):
backend._commit_effective_parallel_slots(4)
backend.unload_model()
assert backend.effective_parallel_slots == 1

View file

@ -137,9 +137,9 @@ def test_read_install_marker_finds_windows_cmake_layout(tmp_path):
@pytest.mark.parametrize("repo", ["unslothai/llama.cpp", "ggml-org/llama.cpp"])
def test_read_install_marker_carries_published_repo_dynamically(tmp_path, repo):
# The freshness check queries whichever release repo the marker records,
# so CUDA (unslothai), CPU/macOS (ggml-org), and ROCm all get the right
# "latest" tag.
# The freshness check queries whichever release repo the marker records:
# new installs record the fork, legacy CPU/macOS markers still say ggml-org,
# and both must get the right "latest" tag.
install_dir = tmp_path / "llama.cpp"
_write_marker(install_dir, tag = "b9000", published_repo = repo)
bin_path = _fake_binary(install_dir, layout = "cmake")

View file

@ -0,0 +1,81 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
import contextlib
import os
import sys
import threading
import httpx
import pytest
_backend = os.path.join(os.path.dirname(__file__), "..")
sys.path.insert(0, _backend)
from core.inference.llama_cpp import LlamaCppBackend, _LlamaStreamCancelled
def _backend_stub() -> LlamaCppBackend:
backend = LlamaCppBackend.__new__(LlamaCppBackend)
backend._process = object()
backend._healthy = True
backend._port = 48848
backend._effective_context_length = 4096
backend._supports_reasoning = False
backend._reasoning_always_on = False
backend._reasoning_style = "enable_thinking"
backend._supports_preserve_thinking = False
return backend
def test_stream_cancel_uses_internal_exception_not_generator_exit():
class FakeResponse:
status_code = 200
def close(self):
pass
class FakeStream:
def __enter__(self):
return FakeResponse()
def __exit__(self, *_args):
return False
class FakeClient:
def stream(self, *_args, **_kwargs):
return FakeStream()
cancel_event = threading.Event()
with pytest.raises(Exception) as exc_info:
with LlamaCppBackend._stream_with_retry(
FakeClient(),
"http://llama.test/v1/chat/completions",
{},
cancel_event,
):
cancel_event.set()
raise httpx.ReadError("client closed")
assert exc_info.type is _LlamaStreamCancelled
assert not issubclass(exc_info.type, GeneratorExit)
def test_generate_chat_completion_swallows_internal_stream_cancel(monkeypatch):
backend = _backend_stub()
@contextlib.contextmanager
def fake_open_stream(*_args, **_kwargs):
raise _LlamaStreamCancelled
monkeypatch.setattr(backend, "_open_stream", fake_open_stream)
chunks = list(
backend.generate_chat_completion(
[{"role": "user", "content": "hi"}],
cancel_event = threading.Event(),
)
)
assert chunks == []

File diff suppressed because it is too large Load diff

View file

@ -448,6 +448,48 @@ def test_start_update_happy_path(monkeypatch, tmp_path):
assert popen_kwargs["env"]["UNSLOTH_PROGRESS_PERCENT_STEP"] == "5"
def test_start_update_preserves_vulkan_via_env(monkeypatch, tmp_path):
# A Vulkan install (marker asset carries 'vulkan') must re-assert
# UNSLOTH_FORCE_VULKAN on update, or detect_host on a GPU box re-routes to
# CUDA/ROCm and silently replaces the Vulkan build.
install_dir = tmp_path / "llama.cpp"
binary = _write_install(
install_dir,
"b9493",
repo = "ggml-org/llama.cpp",
asset = "llama-b9493-bin-ubuntu-vulkan-x64.tar.gz",
)
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
def _on_start(cmd):
_write_install(
install_dir,
"b9518",
repo = "ggml-org/llama.cpp",
asset = "llama-b9518-bin-ubuntu-vulkan-x64.tar.gz",
)
popen_kwargs: dict = {}
_patch_installer_popen(
monkeypatch,
lines = ["installed\n"],
on_start = _on_start,
captured_kwargs = popen_kwargs,
)
assert upd.start_update()["started"] is True
deadline = time.time() + 10
while time.time() < deadline:
job = upd.get_update_status()["job"]
if job["state"] in ("success", "error"):
break
time.sleep(0.05)
assert job["state"] == "success", job
assert popen_kwargs["env"]["UNSLOTH_FORCE_VULKAN"] == "1"
def test_start_update_reports_full_release_tag(monkeypatch, tmp_path):
install_dir = tmp_path / "llama.cpp"
binary = _write_install(install_dir, "b9595")
@ -594,9 +636,10 @@ def test_install_cmd_fork_rocm_marker_forwards_has_rocm(monkeypatch, tmp_path):
def test_install_cmd_ggml_cpu_marker_has_no_cpu_fallback(monkeypatch, tmp_path):
# CPU installs come from ggml-org. Re-running into the same install-dir/repo
# reproduces the same CPU bundle; --cpu-fallback (which force-drops GPU
# detection) is reserved for setup.sh's arm64 rescue and must not appear here.
# Legacy CPU installs recorded a ggml-org marker (new installs use the fork).
# Re-running into the same install-dir/repo reproduces the same CPU bundle;
# --cpu-fallback (which force-drops GPU detection) is reserved for setup.sh's
# arm64 rescue and must not appear here.
cmd = _capture_install_cmd(
monkeypatch,
tmp_path,

View file

@ -0,0 +1,193 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Vulkan free-VRAM reader regression tests on a synthetic probe output.
Covers the post-probe handling in
``LlamaCppBackend._get_gpu_free_memory_vulkan``:
* integrated GPUs (probe reports is_igpu=1) leave a flat per-device host
margin matching llama.cpp's --fit-target, so context auto-sizing can't
over-commit shared RAM, and report total 0 (shared RAM is not a budget),
* discrete GPUs (is_igpu=0) keep their free untouched and pass their real
total through so the fit can reserve absolute headroom,
* an inherited ``GGML_VK_VISIBLE_DEVICES`` is passed through to ggml unchanged
(ggml applies it), not stripped or filtered in Python -- the probe reports
ggml's compact ordinal, which load_model pins with ``--device Vulkan<i>``.
The ggml Vulkan library is never loaded: subprocess.run is mocked to emit
the tab-separated lines the real ``_vulkan_probe.py`` would print.
"""
from __future__ import annotations
import subprocess
import sys
import types as _types
from pathlib import Path
from unittest import mock
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
import importlib as _importlib # noqa: E402
def _maybe_stub(name: str, builder):
try:
_importlib.import_module(name)
except ImportError:
sys.modules[name] = builder()
def _build_loggers_stub():
m = _types.ModuleType("loggers")
m.get_logger = lambda name: __import__("logging").getLogger(name)
return m
_maybe_stub("loggers", _build_loggers_stub)
_maybe_stub("structlog", lambda: _types.ModuleType("structlog"))
from core.inference import llama_cpp as _llama_mod # noqa: E402
from core.inference.llama_cpp import ( # noqa: E402
LlamaCppBackend,
_llama_lib_dir,
_vulkan_lib_filename,
)
MIB = 1024 * 1024
GIB = 1024 * MIB
def _make_vulkan_install(tmp_path: Path) -> str:
"""A binary whose sibling dir holds the Vulkan ggml lib, so the
reader's ``is_vulkan_backend`` sibling-file check passes."""
bindir = tmp_path / "build" / "bin"
bindir.mkdir(parents = True)
binary = bindir / ("llama-server.exe" if sys.platform == "win32" else "llama-server")
binary.write_bytes(b"stub")
(bindir / _vulkan_lib_filename()).write_bytes(b"stub")
return str(binary)
def _mock_probe(rows: list[str], captured_env: dict | None = None):
"""Patch subprocess.run so the _vulkan_probe.py call returns ``rows``
(already tab-formatted), recording the env it was launched with."""
real_run = subprocess.run
def fake_run(cmd, *args, **kwargs):
if isinstance(cmd, list) and any("_vulkan_probe" in str(c) for c in cmd):
if captured_env is not None:
captured_env.clear()
captured_env.update(kwargs.get("env") or {})
return subprocess.CompletedProcess(
args = cmd, returncode = 0, stdout = "\n".join(rows), stderr = ""
)
return real_run(cmd, *args, **kwargs)
return mock.patch("subprocess.run", side_effect = fake_run)
def _row(
idx: int,
free_bytes: int,
is_igpu: int,
total_bytes: int = 0,
) -> str:
return f"{idx}\t{free_bytes}\t{is_igpu}\t{total_bytes}"
def test_integrated_gpu_leaves_host_margin(tmp_path):
binary = _make_vulkan_install(tmp_path)
# iGPU with 30 GiB free; reserve a flat 1024 MiB (llama.cpp --fit-target).
# total stays 0: shared system RAM is not a VRAM budget for the fit.
rows = [_row(0, 30 * GIB, is_igpu = 1, total_bytes = 32 * GIB)]
with _mock_probe(rows):
gpus = LlamaCppBackend._get_gpu_free_memory_vulkan(binary)
assert gpus == [(0, 30 * 1024 - 1024, 0)], gpus
def test_discrete_gpu_free_is_untouched_and_total_passed_through(tmp_path):
binary = _make_vulkan_install(tmp_path)
# 6 GiB free on a partially occupied 24 GiB card: free is untouched and the
# real total flows through so the fit reserves absolute headroom (CUDA/ROCm
# parity) instead of the looser free*frac budget.
rows = [_row(0, 6 * GIB, is_igpu = 0, total_bytes = 24 * GIB)]
with _mock_probe(rows):
gpus = LlamaCppBackend._get_gpu_free_memory_vulkan(binary)
assert gpus == [(0, 6 * 1024, 24 * 1024)], gpus
def test_large_discrete_gpu_is_untouched(tmp_path):
binary = _make_vulkan_install(tmp_path)
# A 48 GiB discrete card stays untouched regardless of size; only the
# iGPU flag triggers the host margin, never a VRAM/RAM ratio.
rows = [_row(0, 47 * GIB, is_igpu = 0, total_bytes = 48 * GIB)]
with _mock_probe(rows):
gpus = LlamaCppBackend._get_gpu_free_memory_vulkan(binary)
assert gpus == [(0, 47 * 1024, 48 * 1024)], gpus
def test_inherited_visible_devices_mask_is_passed_through_to_probe(tmp_path, monkeypatch):
# The mask is NOT stripped or filtered in Python: ggml parses it in raw
# physical-device space while this probe reports the compact post-filter
# ordinal, so mixing spaces would be wrong. It is passed through unchanged
# so ggml applies it to the same device list the launch will enumerate.
binary = _make_vulkan_install(tmp_path)
monkeypatch.setenv("GGML_VK_VISIBLE_DEVICES", "1")
captured: dict = {}
rows = [_row(0, 23 * GIB, is_igpu = 0, total_bytes = 24 * GIB)]
with _mock_probe(rows, captured_env = captured):
LlamaCppBackend._get_gpu_free_memory_vulkan(binary)
assert captured.get("GGML_VK_VISIBLE_DEVICES") == "1", captured
def test_vulkan_pin_args_uses_device_names_not_env_mask():
# Pin by compact device name via --device (the space the probe reports and
# the registry names), never by writing a compact ordinal into the raw
# GGML_VK_VISIBLE_DEVICES index space.
assert LlamaCppBackend._vulkan_pin_args([0]) == ["--device", "Vulkan0"]
assert LlamaCppBackend._vulkan_pin_args([1, 2]) == ["--device", "Vulkan1,Vulkan2"]
assert LlamaCppBackend._vulkan_pin_args(None) == []
assert LlamaCppBackend._vulkan_pin_args([]) == []
def test_vulkan_only_build_is_detected(tmp_path):
binary = _make_vulkan_install(tmp_path)
assert LlamaCppBackend._is_vulkan_backend(binary) is True
def test_multi_backend_build_is_not_vulkan_only(tmp_path):
# A custom build that ships CUDA (or HIP) alongside Vulkan must NOT be
# treated as Vulkan-only, or its CUDA GPU would be probed/pinned as a Vulkan
# device; defer to the CUDA/HIP path instead.
binary = _make_vulkan_install(tmp_path)
cuda = "ggml-cuda.dll" if sys.platform == "win32" else "libggml-cuda.so"
(_llama_lib_dir(binary) / cuda).write_bytes(b"stub")
assert LlamaCppBackend._is_vulkan_backend(binary) is False
@pytest.mark.skipif(sys.platform == "win32", reason = "shell wrapper fallback is POSIX")
def test_shell_wrapper_entrypoint_resolves_to_real_lib_dir(tmp_path):
# create_exec_entrypoint falls back to a #!/bin/sh wrapper at the install root
# when it cannot symlink; _find_llama_server_binary returns that root entrypoint,
# so _llama_lib_dir must follow the wrapper's exec target to build/bin -- else
# _is_vulkan_backend misses libggml-vulkan.so and the Vulkan probe/pin silently
# never engage on a valid Vulkan install.
import os
binary = _make_vulkan_install(tmp_path) # tmp_path/build/bin/llama-server + vulkan lib
bindir = Path(binary).parent
wrapper = tmp_path / "llama-server"
wrapper.write_text('#!/bin/sh\nexec "$(dirname "$0")/build/bin/llama-server" "$@"\n')
os.chmod(wrapper, 0o755)
assert _llama_lib_dir(str(wrapper)) == bindir
assert LlamaCppBackend._is_vulkan_backend(str(wrapper)) is True
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))

View file

@ -203,3 +203,87 @@ def test_preheader_send_cleanup_on_disconnect_and_cancel():
asyncio.run(_run(False))
asyncio.run(_run(True))
def test_stream_stall_timeout_callable_re_resolved_each_read():
# The OpenAI passthrough passes a callable so the stall bound can switch to
# the short post-terminal grace mid-stream; it must be re-resolved per read,
# not captured once at generator start.
async def _run():
response = SimpleNamespace(request = SimpleNamespace(extensions = {"timeout": {}}))
values = iter([100.0, 2.0])
seen = []
class _Request:
async def is_disconnected(self):
return False
class _Items:
def __init__(self):
self.count = 0
async def __anext__(self):
self.count += 1
if self.count > 3:
raise StopAsyncIteration
return "data: {}"
async for _ in inf_mod._aiter_llama_stream_items(
_Items(),
cancel_event = threading.Event(),
request = _Request(),
response = response,
first_token_deadline = time.monotonic() + 1,
post_first_item_read_timeout_s = lambda: next(values, 5.0),
):
seen.append(response.request.extensions["timeout"].get("read"))
assert len(seen) == 3
# The callable is resolved right after the first item (arming the
# post-first window) and again before each later read, consuming
# successive values.
assert seen[0] == 100.0
assert 1.0 <= seen[1] <= 2.0
assert 4.0 <= seen[2] <= 5.0
asyncio.run(_run())
def test_stream_stall_timeout_disabled_clears_read_timeout():
# UNSLOTH_OPENAI_COMPAT_STREAM_STALL_TIMEOUT=0 disables the stall guard, so
# the callable returns None. Once a chunk has arrived the leftover
# first-token read timeout must be cleared, else a long post-first-chunk gap
# trips a stale deadline the operator asked to turn off.
async def _run():
response = SimpleNamespace(request = SimpleNamespace(extensions = {"timeout": {}}))
seen = []
class _Request:
async def is_disconnected(self):
return False
class _Items:
def __init__(self):
self.count = 0
async def __anext__(self):
self.count += 1
if self.count > 2:
raise StopAsyncIteration
return "data: {}"
async for _ in inf_mod._aiter_llama_stream_items(
_Items(),
cancel_event = threading.Event(),
request = _Request(),
response = response,
first_token_deadline = time.monotonic() + 5,
post_first_item_read_timeout_s = lambda: None,
):
seen.append(response.request.extensions["timeout"].get("read"))
# The first-token path armed a finite read timeout; after the first chunk
# with the guard disabled, it is cleared to None on every subsequent read.
assert seen == [None, None], seen
asyncio.run(_run())

View file

@ -587,10 +587,12 @@ def test_tool_xml_strip_handles_hyphenated_function_names():
import re as _re
from pathlib import Path
from core.inference.tool_call_parser import _DEEPSEEK_OPEN_RE_SRC as _DS_OPEN_SRC
src = (Path(__file__).resolve().parent.parent / "routes/inference.py").read_text()
m = _re.search(r"_TOOL_XML_RE = _re\.compile\((.*?)\n\)", src, _re.DOTALL)
assert m, "could not extract _TOOL_XML_RE"
ns: dict = {"_re": _re}
ns: dict = {"_re": _re, "_DS_OPEN_SRC": _DS_OPEN_SRC}
exec(f"_TOOL_XML_RE = _re.compile({m.group(1)})", ns)
rx = ns["_TOOL_XML_RE"]
stripped = rx.sub(

View file

@ -4,6 +4,8 @@ import sys
import types
from types import SimpleNamespace
import pytest
class _DummyMetal:
@staticmethod
@ -100,6 +102,32 @@ def test_mlx_inference_text_load_forwards_studio_settings(monkeypatch):
]
assert backend._is_vlm is False
assert isinstance(backend._tokenizer, _DummyTokenizer)
# Non-LoRA text model: no base_model on the record.
assert backend.models["fake/text"]["base_model"] is None
def test_mlx_text_lora_record_keeps_base_model_for_native_template(monkeypatch):
# A LoRA adapter's own tokenizer often ships no chat template; the native tool-calling template
# lives on the base model.
_install_fake_mlx(monkeypatch)
calls = []
_install_fake_fast_mlx(monkeypatch, calls)
from core.inference.mlx_inference import MLXInferenceBackend
backend = MLXInferenceBackend()
config = SimpleNamespace(
identifier = "fake/text-adapter",
is_vision = False,
is_lora = True,
base_model = "fake/text-base",
)
assert backend.load_model(config, max_seq_length = 4096, hf_token = "hf-token")
record = backend.models["fake/text-adapter"]
assert record["is_lora"] is True
assert record["base_model"] == "fake/text-base"
def test_mlx_inference_vlm_lora_uses_unsloth_loader_without_native_adapter_rewrite(
@ -159,6 +187,129 @@ def test_mlx_inference_vlm_lora_uses_unsloth_loader_without_native_adapter_rewri
assert isinstance(backend._tokenizer, _DummyTokenizer)
def test_mlx_inference_distributed_vlm_forwards_group_to_fast_mlx(monkeypatch):
_install_fake_mlx(monkeypatch)
calls = []
_install_fake_fast_mlx(monkeypatch, calls)
from core.inference.mlx_inference import MLXInferenceBackend
group = SimpleNamespace(size = lambda: 2, rank = lambda: 0)
config = SimpleNamespace(identifier = "fake/vlm", is_vision = True, is_lora = False)
for mode, group_key in (("tensor", "tensor_group"), ("pipeline", "pipeline_group")):
calls.clear()
assert MLXInferenceBackend().load_model(config, parallel_mode = mode, distributed_group = group)
_, kwargs = calls.pop()
assert kwargs["text_only"] is False and kwargs[group_key] is group
calls.clear()
singleton = SimpleNamespace(size = lambda: 1, rank = lambda: 0)
assert MLXInferenceBackend().load_model(
config, parallel_mode = "tensor", distributed_group = singleton
)
assert not {"tensor_group", "pipeline_group"} & set(calls.pop()[1])
config = SimpleNamespace(identifier = "fake/adapter", is_vision = False, is_lora = True)
with pytest.raises(ValueError, match = "LoRA adapter repos"):
MLXInferenceBackend().load_model(config, parallel_mode = "tensor", distributed_group = group)
@pytest.mark.parametrize("accepts_backend", (True, False))
def test_mlx_distributed_init_selects_jaccl_backend(monkeypatch, accepts_backend):
_install_fake_mlx(monkeypatch)
from core.inference.mlx_inference import _init_mlx_distributed
group = SimpleNamespace(rank = lambda: 1, size = lambda: 2)
calls = []
def _init(**kwargs):
calls.append(kwargs)
if kwargs and not accepts_backend:
raise TypeError("backend keyword unsupported")
return group
sys.modules["mlx.core"].distributed = SimpleNamespace(init = _init)
monkeypatch.setenv("MLX_JACCL_COORDINATOR", "127.0.0.1:12345")
monkeypatch.setenv("MLX_IBV_DEVICES", "/tmp/devices.json")
assert _init_mlx_distributed() == (group, 1, 2)
assert calls == ([{"backend": "jaccl"}] if accepts_backend else [{"backend": "jaccl"}, {}])
def test_worker_share_object_receives_distributed_payload(monkeypatch):
from core.inference import worker
shared_obj = {"type": "turn", "text": "hi"}
payload = worker._encode_share_object(shared_obj)
def _array(value):
val = value.item() if hasattr(value, "item") else value
return SimpleNamespace(
item = lambda: val,
tolist = lambda: list(val) if hasattr(val, "__iter__") else [val],
)
mlx_pkg = types.ModuleType("mlx")
mlx_core = types.ModuleType("mlx.core")
mlx_core.uint8 = "uint8"
mlx_core.array = _array
mlx_core.zeros = lambda *_a, **_k: _array([])
def _all_sum(value, group = None):
value = value.item() if hasattr(value, "item") else value
return _array(len(payload)) if value == 0 else _array(payload)
mlx_core.distributed = SimpleNamespace(all_sum = _all_sum)
mlx_pkg.core = mlx_core
monkeypatch.setitem(sys.modules, "mlx", mlx_pkg)
monkeypatch.setitem(sys.modules, "mlx.core", mlx_core)
responses = []
worker._handle_share_object(
SimpleNamespace(
_distributed_group = object(),
_distributed_rank = 1,
_distributed_world_size = 2,
),
{"type": "share_object", "request_id": "rid", "object": None},
SimpleNamespace(put = responses.append),
)
response = responses[0]
assert response["object"] == shared_obj
def test_worker_share_object_oversize_notifies_peers(monkeypatch):
from core.inference import worker
calls = []
mlx_pkg = types.ModuleType("mlx")
mlx_core = types.ModuleType("mlx.core")
mlx_core.array = lambda value, **_kwargs: SimpleNamespace(item = lambda: value)
mlx_core.eval = lambda value: value
mlx_core.distributed = SimpleNamespace(
all_sum = lambda value, group = None: calls.append(value.item()) or value
)
mlx_pkg.core = mlx_core
monkeypatch.setitem(sys.modules, "mlx", mlx_pkg)
monkeypatch.setitem(sys.modules, "mlx.core", mlx_core)
monkeypatch.setattr(worker, "_SHARE_OBJECT_MAX_BYTES", 8)
responses = []
worker._handle_share_object(
SimpleNamespace(
_distributed_group = object(),
_distributed_rank = 0,
_distributed_world_size = 2,
),
{"type": "share_object", "request_id": "rid", "object": {"text": "too long"}},
SimpleNamespace(put = responses.append),
)
assert calls == [worker._SHARE_OBJECT_ERROR_SIZE]
assert responses[0]["type"] == "share_error"
# Regression: generate_chat_response must accept the four template kwargs
# (tools / enable_thinking / reasoning_effort / preserve_thinking) so the route
# layer can forward UI toggles. The old signature raised
@ -188,12 +339,12 @@ def test_mlx_generate_text_forwards_kwargs_into_template_helper(monkeypatch):
_install_fake_mlx(monkeypatch)
from core.inference.mlx_inference import MLXInferenceBackend
captured = {}
# The text path renders once with tools, then the native-template fallback makes a second no-
# tools probe call (tools=None) to detect whether the template dropped the schema.
captured_calls = []
def _fake_apply(tokenizer, messages, **kwargs):
captured["tokenizer"] = tokenizer
captured["messages"] = messages
captured["kwargs"] = kwargs
captured_calls.append({"tokenizer": tokenizer, "messages": messages, "kwargs": kwargs})
return "<rendered prompt>"
monkeypatch.setattr(
@ -248,8 +399,15 @@ def test_mlx_generate_text_forwards_kwargs_into_template_helper(monkeypatch):
)
)
assert out == ["hi"]
# The toggled kwargs must reach the chat-template helper.
assert captured["kwargs"]["tools"] == [{"function": {"name": "web_search"}}]
assert captured["kwargs"]["enable_thinking"] is True
assert captured["kwargs"]["reasoning_effort"] == "medium"
assert captured["kwargs"]["preserve_thinking"] is True
# The toggled kwargs must reach the chat-template helper on the real render
# (one of the calls carries the tools; the fallback probe passes tools=None).
tool_renders = [
c
for c in captured_calls
if c["kwargs"].get("tools") == [{"function": {"name": "web_search"}}]
]
assert tool_renders, captured_calls
render = tool_renders[0]
assert render["kwargs"]["enable_thinking"] is True
assert render["kwargs"]["reasoning_effort"] == "medium"
assert render["kwargs"]["preserve_thinking"] is True

View file

@ -271,6 +271,29 @@ def test_stack_available_requires_runtime_imports_and_versions(monkeypatch):
assert imported == list(mr._MLX_RUNTIME_IMPORTS)
def test_mlx_packages_exclude_known_bad_mlx_lm():
# mlx-lm 0.31.3 regressed QK-norm archs (gemma4 / qwen3_5); the install spec
# must exclude it so the resolver picks 0.31.2 or >=0.31.4. See mlx-lm #1242.
(mlx_lm_spec,) = [p for p in mr.MLX_PACKAGES if p.startswith("mlx-lm")]
assert mlx_lm_spec == "mlx-lm>=0.22.0,!=0.31.3"
@pytest.mark.parametrize("bad_form", ["0.31.3", "0.31.3.0"])
def test_known_bad_installed_mlx_lm_triggers_repair(monkeypatch, bad_form):
# An installed 0.31.3 counts as unsatisfied so the self-heal replaces it;
# parsed-Version compare also catches the trailing-zero form 0.31.3.0.
import importlib.metadata as metadata
def _version(name):
return bad_form if name == "mlx-lm" else mr._MLX_MIN_VERSIONS[name]
monkeypatch.setattr(metadata, "version", _version)
monkeypatch.setattr(
mr.importlib, "import_module", lambda _n: pytest.fail("versions must gate imports")
)
assert mr.mlx_stack_available() is False
def test_no_op_off_apple_silicon(monkeypatch):
monkeypatch.setattr(mr, "is_apple_silicon", lambda: False)
called = {"n": 0}

View file

@ -5,8 +5,8 @@
Covers:
* GGUF variant listing computes update_available from the already-fetched
sibling metadata instead of a second Hub call.
* hf_hub_download_with_xet_fallback(force_download=True) bypasses the
try_to_load_from_cache cache-first early-return.
* hf_hub_download_with_xet_fallback forwards force_download through the shim to the
shared unsloth_zoo helper (which owns the cache-first early-return and its bypass).
The cache "Update" action now runs through the download manager as a normal
managed download (so it shows in the Downloads panel with progress + cancel),
@ -341,44 +341,26 @@ def test_cached_model_scan_keeps_local_safetensors_repo(monkeypatch, tmp_path):
# ── hf_hub_download_with_xet_fallback force_download bypass (X2/F2) ───
def test_force_download_bypasses_cache_first_early_return(monkeypatch):
"""force_download=True skips the try_to_load_from_cache early-return and
proceeds to the real download path; force_download=False returns the cached
path without ever attempting a download (X2/F2)."""
import huggingface_hub as hf
def test_force_download_is_forwarded_through_the_shim(monkeypatch):
"""The shim's contract is to forward force_download unchanged to the shared helper (which owns the
cache-first early-return and bypass). Verify both False and True reach it (X2/F2)."""
import utils.hf_xet_fallback as X
cached_path = "/cache/blob/cached.gguf"
seen = []
# Pretend the blob IS cached on disk (try_to_load_from_cache is imported
# inside the function from huggingface_hub, and os.path.exists must agree).
monkeypatch.setattr(hf, "try_to_load_from_cache", lambda *a, **k: cached_path, raising = False)
monkeypatch.setattr(X.os.path, "exists", lambda p: True, raising = False)
def fake_shared(repo_id, filename, token, **kwargs):
seen.append(kwargs.get("force_download"))
return "/downloaded/path"
attempts = []
monkeypatch.setattr(X, "_shared_hf_hub_download_with_xet_fallback", fake_shared, raising = True)
def fake_attempt(repo_id, filename, token, **kwargs):
attempts.append(
{"repo_id": repo_id, "filename": filename, "force": kwargs.get("force_download")}
)
return ("ok", "/freshly/downloaded/path")
monkeypatch.setattr(X, "_run_download_attempt", fake_attempt, raising = True)
# force_download=False: cache-first early-return, no download attempt.
out = X.hf_hub_download_with_xet_fallback(
X.hf_hub_download_with_xet_fallback(
"unsloth/repo", "model.gguf", token = None, force_download = False
)
assert out == cached_path
assert attempts == [] # never reached the real download
# force_download=True: bypass the early-return, run the real download.
out2 = X.hf_hub_download_with_xet_fallback(
X.hf_hub_download_with_xet_fallback(
"unsloth/repo", "model.gguf", token = None, force_download = True
)
assert out2 == "/freshly/downloaded/path"
assert len(attempts) == 1
assert attempts[0]["force"] is True
assert seen == [False, True] # the shim forwards force_download to the shared helper unchanged
# ── multi-revision GGUF blob comparison and update reclaim ──

View file

@ -23,7 +23,11 @@ if _BACKEND_DIR not in sys.path:
from hub.utils.download_manifest import ExpectedFile
from hub.utils.gguf import is_mtp_drafter_path
from hub.utils.gguf_plan import build_gguf_variant_plans, plan_from_expected_files
from hub.utils.gguf_plan import (
build_gguf_variant_plans,
plan_from_expected_files,
preferred_mtp_sibling,
)
from utils.models.model_config import (
_is_mtp_drafter,
detect_gguf_model,
@ -37,6 +41,8 @@ from utils.models.model_config import (
DRAFTER_CASES = [
("mtp-gemma-4-12b-it.gguf", True),
("MTP/gemma-4-12b-it-Q8_0-MTP.gguf", True),
# New-scheme MTP/ copies carry the mtp- basename prefix too.
("MTP/mtp-gemma-4-E4B-it-BF16.gguf", True),
("foo/MTP/bar.gguf", True),
("gemma-4-12b-it-Q8_0.gguf", False),
# Baked-in Qwen MTP repos: the head is inside the main GGUF, the file
@ -274,3 +280,178 @@ def test_detect_gguf_model_rejects_mtp_subdir_copy(tmp_path):
assert detect_gguf_model(str(copy)) is None
# Selecting the MTP dir itself must not surface the copies as models.
assert detect_gguf_model(str(sub)) is None
# ── Root drafter wins over new-scheme MTP/ copies ────────────────────
# The MTP/ copies were renamed to share the mtp- basename prefix (e.g.
# MTP/mtp-gemma-4-E4B-it-BF16.gguf). Auto-fetch/load must still resolve the
# small repo-root drafter, not a sort-first MTP/ copy (uppercase precedes
# lowercase, so the subdir path would otherwise win).
NEW_SCHEME_SIBLINGS = [
_sib("gemma-4-12b-it-Q4_K_M.gguf", 4_000, "main-q4"),
_sib("gemma-4-12b-it-Q8_0.gguf", 8_000, "main-q8"),
_sib("mtp-gemma-4-12b-it.gguf", 100, "drafter"),
_sib("MTP/mtp-gemma-4-12b-it-Q8_0.gguf", 100, "mtp-sub-q8"),
_sib("MTP/mtp-gemma-4-12b-it-BF16.gguf", 200, "mtp-sub-bf16"),
_sib("mmproj-F16.gguf", 500, "mmproj"),
]
def test_preferred_mtp_sibling_prefers_root_over_new_scheme_copies():
picked = preferred_mtp_sibling(NEW_SCHEME_SIBLINGS)
assert picked is not None and picked.rfilename == "mtp-gemma-4-12b-it.gguf"
def test_variant_plans_new_scheme_uses_root_drafter():
plans = build_gguf_variant_plans(NEW_SCHEME_SIBLINGS)
assert set(plans) == {"q4_k_m", "q8_0"}
for plan in plans.values():
assert "mtp-gemma-4-12b-it.gguf" in plan.target_filenames
assert not any("MTP/" in name for name in plan.target_filenames)
assert "drafter" in plan.companion_hashes
# Download size = main + mmproj + root drafter (not the 200-byte BF16 copy).
assert plans["q4_k_m"].download_size_bytes == 4_600
def test_download_mtp_prefers_root_over_new_scheme_copies(monkeypatch):
# _pick_mtp is nested; capture it via the companion-download seam.
from core.inference.llama_cpp import LlamaCppBackend
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False) # online: skip reuse probe
captured = {}
def _fake_companion(
*,
hf_repo,
hf_token,
pick,
label,
cancel_event = None,
):
captured["pick"] = pick
return None
b = LlamaCppBackend()
b._download_companion_gguf = _fake_companion
b._download_mtp(hf_repo = "unsloth/gemma-4-E4B-it-qat-mobile-GGUF")
repo_files = [
"MTP/mtp-gemma-4-E4B-it-BF16.gguf",
"MTP/mtp-gemma-4-E4B-it-Q4_0.gguf",
"MTP/mtp-gemma-4-E4B-it-Q8_0.gguf",
"gemma-4-E4B-it-qat-UD-Q2_K_XL.gguf",
"mmproj-F16.gguf",
"mtp-gemma-4-E4B-it.gguf",
]
assert captured["pick"](repo_files) == "mtp-gemma-4-E4B-it.gguf"
# ── Reuse an on-disk drafter offline; fetch fresh online ─────────────
def _seed_snapshot(tmp_path, names):
snap = tmp_path / "snap"
for rel in names:
f = snap / rel
f.parent.mkdir(parents = True, exist_ok = True)
f.write_bytes(b"x")
return snap
def test_download_mtp_reuses_cached_root_drafter_offline(tmp_path, monkeypatch):
import utils.models.model_config as mc
from core.inference.llama_cpp import LlamaCppBackend
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
snap = _seed_snapshot(
tmp_path,
[
"gemma-4-E4B-it-qat-UD-Q2_K_XL.gguf",
"mtp-gemma-4-E4B-it.gguf",
"MTP/mtp-gemma-4-E4B-it-BF16.gguf",
"mmproj-F16.gguf",
],
)
monkeypatch.setattr(mc, "_iter_hf_cache_snapshots", lambda repo: [snap])
got = LlamaCppBackend()._download_mtp(hf_repo = "unsloth/gemma-4-E4B-it-qat-mobile-GGUF")
assert got is not None and Path(got).name == "mtp-gemma-4-E4B-it.gguf"
def test_download_mtp_reuses_cached_subdir_copy_when_no_root_offline(tmp_path, monkeypatch):
# Pre-fix build may have fetched only the MTP/ copy; reuse it offline.
import utils.models.model_config as mc
from core.inference.llama_cpp import LlamaCppBackend
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
snap = _seed_snapshot(
tmp_path,
[
"gemma-4-E4B-it-qat-UD-Q2_K_XL.gguf",
"MTP/mtp-gemma-4-E4B-it-BF16.gguf",
],
)
monkeypatch.setattr(mc, "_iter_hf_cache_snapshots", lambda repo: [snap])
got = LlamaCppBackend()._download_mtp(hf_repo = "unsloth/gemma-4-E4B-it-qat-mobile-GGUF")
assert got is not None and Path(got).name == "mtp-gemma-4-E4B-it-BF16.gguf"
def test_download_mtp_prefers_root_across_snapshots_offline(tmp_path, monkeypatch):
# A newer partial snapshot holds only the MTP/ copy; an older one has the
# root. Must still return the small root, not the large subdir copy.
import utils.models.model_config as mc
from core.inference.llama_cpp import LlamaCppBackend
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
snap_partial = _seed_snapshot(tmp_path / "new", ["MTP/mtp-gemma-4-E4B-it-BF16.gguf"])
snap_full = _seed_snapshot(tmp_path / "old", ["mtp-gemma-4-E4B-it.gguf"])
monkeypatch.setattr(mc, "_iter_hf_cache_snapshots", lambda repo: [snap_partial, snap_full])
got = LlamaCppBackend()._download_mtp(hf_repo = "unsloth/gemma-4-E4B-it-qat-mobile-GGUF")
assert got is not None and Path(got).name == "mtp-gemma-4-E4B-it.gguf"
def test_download_mtp_reuse_follows_snapshot_order_offline(tmp_path, monkeypatch):
# Two snapshots both hold a root drafter; newest-first order must win so a
# fresh main GGUF is not paired with a stale drafter revision.
import utils.models.model_config as mc
from core.inference.llama_cpp import LlamaCppBackend
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
newest = _seed_snapshot(tmp_path / "newest", ["mtp-gemma-4-E4B-it.gguf"])
oldest = _seed_snapshot(tmp_path / "oldest", ["mtp-gemma-4-E4B-it.gguf"])
monkeypatch.setattr(mc, "_iter_hf_cache_snapshots", lambda repo: [newest, oldest])
got = LlamaCppBackend()._download_mtp(hf_repo = "unsloth/gemma-4-E4B-it-qat-mobile-GGUF")
assert got is not None and Path(got).parent.parent.name == "newest"
def test_download_mtp_online_skips_cache_reuse(tmp_path, monkeypatch):
# Online, do not reuse a cached copy: go to the download path so a changed
# drafter is refetched (hf_hub_download checks the current revision).
import utils.models.model_config as mc
from core.inference.llama_cpp import LlamaCppBackend
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
snap = _seed_snapshot(tmp_path, ["mtp-gemma-4-E4B-it.gguf"])
monkeypatch.setattr(mc, "_iter_hf_cache_snapshots", lambda repo: [snap])
reached = {}
def _fake_companion(
*,
hf_repo,
hf_token,
pick,
label,
cancel_event = None,
):
reached["hit"] = True
return None
b = LlamaCppBackend()
b._download_companion_gguf = _fake_companion
assert b._download_mtp(hf_repo = "unsloth/gemma-4-E4B-it-qat-mobile-GGUF") is None
assert reached.get("hit") is True

View file

@ -0,0 +1,176 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Regression tests for trust_remote_code in the native-template fallback.
``render_native_template`` re-fetches a model's native chat template from its
repo when an Unsloth override template (mistral, gemma-4) dropped the tools
schema. For a model loaded with ``trust_remote_code=True`` whose tokenizer repo
carries custom code, the secondary ``AutoTokenizer.from_pretrained`` must re-use
that same consent or transformers raises (it requires ``trust_remote_code`` to
instantiate a custom tokenizer class), the ``except`` swallows it, and the
request silently keeps the tool-dropping prompt even though the user already
consented to remote code for the model load.
These tests pin that the stored ``trust_remote_code`` is threaded to the reload,
that the reload is skipped (returns ``None`` without executing code) when no
consent is stored, and that both backend ``model_info`` dicts persist the flag at
load time so the read lands on a value ``load_model`` actually set.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# ``chat_template_helpers`` is dependency-light (copy / logging / typing, with the
# transformers import deferred inside the function). Load it directly so the test
# runs without importing the heavy ``core.inference`` package (unsloth / torch).
_HELPERS_PATH = Path(_BACKEND_DIR) / "core" / "inference" / "chat_template_helpers.py"
_spec = importlib.util.spec_from_file_location("_native_tpl_trc_test", _HELPERS_PATH)
chat_template_helpers = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(chat_template_helpers)
render_native_template = chat_template_helpers.render_native_template
# A native template that emits a tools section only when tools are provided, so the
# with-tools vs no-tools render differs and ``render_native_template`` accepts it.
_NATIVE_TEMPLATE = (
"{% for m in messages %}{{ m['role'] }}: {{ m['content'] }}\n{% endfor %}"
"{% if tools %}[AVAILABLE_TOOLS]{{ tools }}[/AVAILABLE_TOOLS]\n{% endif %}"
"{% if add_generation_prompt %}assistant:{% endif %}"
)
_MESSAGES = [{"role": "user", "content": "what is the weather"}]
_TOOLS = [{"type": "function", "function": {"name": "get_weather"}}]
class _JinjaTokenizer:
"""Minimal tokenizer whose ``apply_chat_template`` renders ``self.chat_template``.
Stands in for the live model tokenizer that ``render_native_template`` shallow-
copies and re-points at the native template before rendering.
"""
def __init__(self, chat_template):
self.chat_template = chat_template
def apply_chat_template(
self,
messages,
tokenize = False,
add_generation_prompt = True,
tools = None,
**kwargs,
):
from jinja2 import BaseLoader, Environment
env = Environment(loader = BaseLoader())
return env.from_string(self.chat_template).render(
messages = messages,
tools = tools,
add_generation_prompt = add_generation_prompt,
)
def _install_custom_code_tokenizer(monkeypatch):
"""Patch ``AutoTokenizer.from_pretrained`` to mimic a custom-code repo: raise
unless ``trust_remote_code`` is truthy, else return a tokenizer carrying the
native template. Records the ``trust_remote_code`` it was called with."""
pytest.importorskip("jinja2")
from transformers import AutoTokenizer
calls = {}
def fake_from_pretrained(
model_id,
*args,
trust_remote_code = False,
token = None,
**kwargs,
):
calls["trust_remote_code"] = trust_remote_code
calls["model_id"] = model_id
calls["token"] = token
if not trust_remote_code:
# Mirrors transformers.dynamic_module_utils.resolve_trust_remote_code:
# has_remote_code and not has_local_code and not trust_remote_code -> ValueError.
raise ValueError(
f"The repository {model_id} contains custom code which must be executed "
"to correctly load the model. Please pass the argument "
"`trust_remote_code=True` to allow custom code to be run."
)
return _JinjaTokenizer(_NATIVE_TEMPLATE)
monkeypatch.setattr(AutoTokenizer, "from_pretrained", staticmethod(fake_from_pretrained))
return calls
def _model_info(trust_remote_code):
return {
"native_chat_template": None, # force the repo reload path
"base_model": None, # non-LoRA: template_source == active_model_name
"trust_remote_code": trust_remote_code,
# Live tokenizer that gets shallow-copied + re-pointed at the native template.
"tokenizer": _JinjaTokenizer("OVERRIDE-THAT-DROPS-TOOLS"),
}
def test_native_reload_passes_stored_trust_remote_code(monkeypatch):
"""With ``trust_remote_code`` stored on ``model_info`` the custom-code reload
succeeds and the tools-advertising native prompt is returned. This FAILS before
the fix (reload omits the flag, raises, is swallowed, returns None)."""
calls = _install_custom_code_tokenizer(monkeypatch)
model_info = _model_info(trust_remote_code = True)
out = render_native_template(
model_info = model_info,
active_model_name = "acme/custom-tokenizer-model",
messages = _MESSAGES,
tools = _TOOLS,
)
assert out is not None, "native fallback should render the tools prompt with consent"
assert "[AVAILABLE_TOOLS]" in out
assert "get_weather" in out
assert calls["trust_remote_code"] is True # the stored consent was threaded through
# A successful fetch is cached so the next tool turn skips the reload.
assert model_info["native_chat_template"] == _NATIVE_TEMPLATE
def test_native_reload_without_consent_returns_none(monkeypatch):
"""Without stored consent the custom-code reload raises, is swallowed, and
``render_native_template`` returns None (no unconsented code execution). Proves
the stored flag -- not a hard-coded True -- drives the reload."""
calls = _install_custom_code_tokenizer(monkeypatch)
model_info = _model_info(trust_remote_code = False)
out = render_native_template(
model_info = model_info,
active_model_name = "acme/custom-tokenizer-model",
messages = _MESSAGES,
tools = _TOOLS,
)
assert out is None
assert calls["trust_remote_code"] is False
# A failed fetch must not be cached as "no template" (would pin the tool drop).
assert model_info["native_chat_template"] is None
def test_backend_model_info_persists_trust_remote_code():
"""Both backends must store ``trust_remote_code`` on their per-model info dict so
``render_native_template`` can source the consent value. Guards against the read
landing on a key ``load_model`` never sets (which would silently no-op the fix)."""
inf = (Path(_BACKEND_DIR) / "core" / "inference" / "inference.py").read_text()
mlx = (Path(_BACKEND_DIR) / "core" / "inference" / "mlx_inference.py").read_text()
assert '"trust_remote_code": trust_remote_code,' in inf
assert '"trust_remote_code": trust_remote_code,' in mlx

View file

@ -0,0 +1,99 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Wiring guard for the plan-without-action ``nudge_tool_calls`` policy.
Decided policy: the re-prompt is ALWAYS ON for the Studio inference paths
(safetensors, GGUF/llama_cpp, MLX) and OPT-IN for the API (/v1 OpenAI-compat +
Anthropic-compat, controlled by the request's ``nudge_tool_calls``, default off).
Mechanism (verified here without loading a model):
* every backend tool-loop entry point accepts and forwards ``nudge_tool_calls``
(safetensors -> ``InferenceBackend``; MLX -> ``InferenceOrchestrator``; both
call the shared ``run_safetensors_tool_loop``; GGUF -> ``LlamaCppBackend``);
* the safetensors/MLX loop gates the retry on a truthy flag (new retry ->
opt-in), while the GGUF loop keeps its pre-existing default-on behaviour
(``None`` keeps nudging) so an omitted flag never disables GGUF;
* the API request models default the flag to ``None`` (opt-in / off);
* the Studio-facing routes forward the request's flag, and the Studio frontend
sends ``nudge_tool_calls: true`` -- exercised behaviourally in
``test_safetensors_tool_loop.py`` and ``test_llama_cpp_tool_loop.py``.
"""
import inspect
from core.inference.llama_cpp import LlamaCppBackend
from core.inference.orchestrator import InferenceOrchestrator
from core.inference.safetensors_agentic import run_safetensors_tool_loop
try:
# core.inference.inference imports unsloth at module scope, which requires
# unsloth_zoo. The dependency-light backend CI matrix job does not install
# it, so the safetensors InferenceBackend is folded into the checks below
# only when the unsloth stack is importable (local runs / full CI); the
# other entry points are always checked.
from core.inference.inference import InferenceBackend
except ImportError:
InferenceBackend = None
def _params(fn):
return inspect.signature(fn).parameters
def test_shared_loop_accepts_nudge_flag():
assert "nudge_tool_calls" in _params(run_safetensors_tool_loop)
def test_backends_accept_the_flag():
methods = [
InferenceOrchestrator.generate_chat_completion_with_tools,
LlamaCppBackend.generate_chat_completion_with_tools,
]
if InferenceBackend is not None: # safetensors path; needs the unsloth stack
methods.append(InferenceBackend.generate_chat_completion_with_tools)
for method in methods:
assert "nudge_tool_calls" in _params(method), method.__qualname__
def test_delegating_backends_forward_the_flag_to_the_shared_loop():
# safetensors (in-process transformers) and MLX (parent-process orchestrator)
# both delegate to run_safetensors_tool_loop; GGUF runs its own in-file loop
# and consumes the flag directly (asserted separately by the gate test).
methods = [InferenceOrchestrator.generate_chat_completion_with_tools]
if InferenceBackend is not None: # safetensors path; needs the unsloth stack
methods.append(InferenceBackend.generate_chat_completion_with_tools)
for method in methods:
src = inspect.getsource(method)
assert "nudge_tool_calls = nudge_tool_calls" in src, method.__qualname__
def test_safetensors_loop_is_opt_in_while_gguf_stays_default_on():
# Safetensors/MLX: the retry is new here, so it requires a truthy flag.
sf_src = inspect.getsource(run_safetensors_tool_loop)
assert "and nudge_tool_calls" in sf_src
# GGUF: pre-existing nudge must not be accidentally disabled -- an omitted
# (None) flag keeps nudging; only an explicit False turns it off.
gguf_src = inspect.getsource(LlamaCppBackend.generate_chat_completion_with_tools)
assert "nudge_tool_calls is None or nudge_tool_calls" in gguf_src
def test_api_request_models_default_the_flag_off():
from models.inference import AnthropicMessagesRequest, ChatCompletionRequest
for model in (ChatCompletionRequest, AnthropicMessagesRequest):
field = model.model_fields["nudge_tool_calls"]
assert field.default is None, model.__name__
def test_studio_routes_forward_the_request_flag():
# The Studio chat frontend posts to /v1/chat/completions and /v1/messages
# with nudge_tool_calls=true; the route handlers forward the request value
# (external API clients that omit it fall back to the opt-in default).
from routes import inference as routes_inference
for handler in (
routes_inference.openai_chat_completions,
routes_inference.anthropic_messages,
):
src = inspect.getsource(handler)
assert "nudge_tool_calls = payload.nudge_tool_calls" in src, handler.__name__

View file

@ -79,9 +79,11 @@ from huggingface_hub import constants as hf_constants
from core.inference.llama_cpp import (
LlamaCppBackend,
_cached_colocated_split_main,
_gguf_files_for_variant,
_hf_offline_if_dns_dead,
_probe_dns_dead,
_resolve_repo_id_casing,
)
from utils.models.model_config import (
_detect_gguf_from_hf_cache,
@ -217,7 +219,7 @@ class TestGgufVariantFileResolution:
downloaded.append(filename)
return f"/fake/{repo_id}/{filename}"
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
with (
patch(
"huggingface_hub.list_repo_files",
@ -239,6 +241,214 @@ class TestGgufVariantFileResolution:
assert downloaded == ["tinyllamas/stories260K.gguf"]
assert out == "/fake/ggml-org/models/tinyllamas/stories260K.gguf"
def test_download_reuses_older_snapshot_when_current_ref_snapshot_is_partial(
self, monkeypatch, hf_cache
):
# Cross-snapshot reuse is an offline-resilience path: online, hf_hub_download
# resumes the partial current-ref download and revalidates the revision instead
# of serving an older snapshot's same-name blob.
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
backend = LlamaCppBackend()
repo = "unsloth/vision-GGUF"
old = _build_cache(
hf_cache,
repo,
{"model-UD-Q4_K_XL.gguf": 4},
snapshot_sha = "a" * 40,
)
_build_cache(
hf_cache,
repo,
{"mtp-model.gguf": 1},
snapshot_sha = "b" * 40,
)
def fake_get_paths_info(
_repo_id,
paths,
token = None,
):
return [_types.SimpleNamespace(path = path, size = 4) for path in paths if path]
def fail_download(*_args, **_kwargs):
raise AssertionError("should reuse the cached GGUF instead of downloading")
with (
patch(
"huggingface_hub.list_repo_files",
lambda *_a, **_k: ["model-UD-Q4_K_XL.gguf", "mtp-model.gguf"],
),
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
patch("huggingface_hub.try_to_load_from_cache", lambda *_a, **_k: None),
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fail_download),
):
out = backend._download_gguf(
hf_repo = repo,
hf_variant = "UD-Q4_K_XL",
)
assert out == str(old / "model-UD-Q4_K_XL.gguf")
def test_download_reuses_cached_gguf_when_lowercase_partial_cache_shadows_it(
self, monkeypatch, hf_cache
):
# Case-variant cross-dir reuse is offline-only; online the canonical repo id
# resolves up front and hf_hub_download fetches the current revision.
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
backend = LlamaCppBackend()
canonical_repo = "unsloth/gemma-4-E2B-it-GGUF"
requested_repo = "unsloth/gemma-4-e2b-it-gguf"
gguf_file = "gemma-4-E2B-it-UD-Q4_K_XL.gguf"
snap = _build_cache(
hf_cache,
canonical_repo,
{gguf_file: 4},
snapshot_sha = "a" * 40,
)
lower_snap = _build_cache(
hf_cache,
requested_repo,
{"mtp-gemma-4-E2B-it.gguf": 1},
snapshot_sha = "b" * 40,
)
os.utime(lower_snap, (2000, 2000))
os.utime(snap, (1000, 1000))
seen_repos: list[str] = []
def fake_list_repo_files(repo_id, token = None):
seen_repos.append(repo_id)
return [gguf_file]
def fake_get_paths_info(
repo_id,
paths,
token = None,
):
seen_repos.append(repo_id)
return [_types.SimpleNamespace(path = path, size = 4) for path in paths if path]
def fake_cache(repo_id, filename, *args, **kwargs):
seen_repos.append(repo_id)
return str(snap / filename) if repo_id == canonical_repo else None
def fail_download(*_args, **_kwargs):
raise AssertionError("should reuse the cached GGUF instead of downloading")
with (
patch("huggingface_hub.list_repo_files", fake_list_repo_files),
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
patch("huggingface_hub.try_to_load_from_cache", fake_cache),
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fail_download),
):
out = backend._download_gguf(
hf_repo = requested_repo,
hf_variant = "UD-Q4_K_XL",
)
assert out == str(snap / gguf_file)
assert seen_repos
def test_download_online_does_not_reuse_old_snapshot(self, monkeypatch, hf_cache):
# Online, an older same-name snapshot must not be served (it may be a stale
# revision); hf_hub_download is called so the current revision is fetched and
# its etag revalidated.
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
backend = LlamaCppBackend()
repo = "unsloth/vision-GGUF"
_build_cache(hf_cache, repo, {"model-UD-Q4_K_XL.gguf": 4}, snapshot_sha = "a" * 40)
downloaded: list[str] = []
def fake_get_paths_info(
_repo_id,
paths,
token = None,
):
return [_types.SimpleNamespace(path = p, size = 4) for p in paths if p]
def fake_download(
repo_id,
filename,
token = None,
**kwargs,
):
downloaded.append(filename)
return f"/fresh/{filename}"
with (
patch(
"huggingface_hub.list_repo_files",
lambda *_a, **_k: ["model-UD-Q4_K_XL.gguf"],
),
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
patch("huggingface_hub.try_to_load_from_cache", lambda *_a, **_k: None),
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fake_download),
):
out = backend._download_gguf(hf_repo = repo, hf_variant = "UD-Q4_K_XL")
assert downloaded == ["model-UD-Q4_K_XL.gguf"]
assert out == "/fresh/model-UD-Q4_K_XL.gguf"
def test_download_reuses_older_snapshot_when_offline_env_is_true(self, monkeypatch, hf_cache):
# HF_HUB_OFFLINE accepts truthy spellings beyond "1" (true/yes/on); the offline
# cache reuse must trigger for those too, otherwise the earlier Hub calls run
# offline while this branch still attempts hf_hub_download and the cached GGUF
# cannot load.
monkeypatch.setenv("HF_HUB_OFFLINE", "true")
backend = LlamaCppBackend()
repo = "unsloth/vision-GGUF"
old = _build_cache(hf_cache, repo, {"model-UD-Q4_K_XL.gguf": 4}, snapshot_sha = "a" * 40)
def fake_get_paths_info(
_repo_id,
paths,
token = None,
):
return [_types.SimpleNamespace(path = p, size = 4) for p in paths if p]
def fail_download(*_args, **_kwargs):
raise AssertionError("should reuse the cached GGUF instead of downloading")
with (
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: ["model-UD-Q4_K_XL.gguf"]),
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
patch("huggingface_hub.try_to_load_from_cache", lambda *_a, **_k: None),
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fail_download),
):
out = backend._download_gguf(hf_repo = repo, hf_variant = "UD-Q4_K_XL")
assert out == str(old / "model-UD-Q4_K_XL.gguf")
def test_download_companion_resolves_from_case_variant_snapshot_offline(
self, monkeypatch, hf_cache
):
# Offline, resolve_cached_repo_id_case can keep a partial lower-case spelling,
# so the companion (mmproj) must resolve from whichever case-variant snapshot
# actually holds it rather than being dropped by an hf_hub_download on the
# wrong casing.
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
backend = LlamaCppBackend()
canonical_repo = "unsloth/gemma-4-E2B-it-GGUF"
requested_repo = "unsloth/gemma-4-e2b-it-gguf"
snap = _build_cache(hf_cache, canonical_repo, {"mmproj-F16.gguf": 4}, snapshot_sha = "a" * 40)
# A partial lower-case dir exists so casing resolution keeps the requested spelling.
_build_cache(hf_cache, requested_repo, {"config.json": 1}, snapshot_sha = "b" * 40)
_offline_exc = type("OfflineModeIsEnabled", (Exception,), {})
def fake_list_repo_files(repo_id, token = None):
raise _offline_exc("offline")
def fail_download(*_args, **_kwargs):
raise AssertionError("should resolve the companion from cache, not download")
with (
patch("huggingface_hub.list_repo_files", fake_list_repo_files),
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fail_download),
):
out = backend._download_mmproj(hf_repo = requested_repo)
assert out == str(snap / "mmproj-F16.gguf")
def test_download_includes_uppercase_split_gguf_shards(self, monkeypatch, tmp_path):
backend = LlamaCppBackend()
downloaded: list[str] = []
@ -264,7 +474,7 @@ class TestGgufVariantFileResolution:
downloaded.append(filename)
return f"/fake/{repo_id}/{filename}"
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
with (
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: files),
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
@ -279,6 +489,48 @@ class TestGgufVariantFileResolution:
assert downloaded == files
assert out == "/fake/org/repo/model-Q4_K_M-00001-of-00002.GGUF"
def test_download_refetches_split_gguf_when_shards_span_snapshots(self, monkeypatch, hf_cache):
# The cached main shard lives in an older snapshot; its sibling shard is only
# in a newer, separate snapshot. Reusing the main shard alone would leave
# llama.cpp unable to resolve the sibling, so the whole set must be re-fetched
# together (co-located) rather than served split across snapshot dirs.
backend = LlamaCppBackend()
repo = "org/split"
files = [
"model-Q4_K_M-00001-of-00002.gguf",
"model-Q4_K_M-00002-of-00002.gguf",
]
_build_cache(hf_cache, repo, {files[0]: 4}, snapshot_sha = "a" * 40)
_build_cache(hf_cache, repo, {files[1]: 4}, snapshot_sha = "b" * 40)
downloaded: list[str] = []
def fake_get_paths_info(
_repo_id,
paths,
token = None,
):
return [_types.SimpleNamespace(path = p, size = 4) for p in paths if p]
def fake_download(
repo_id,
filename,
token = None,
**_kwargs,
):
downloaded.append(filename)
return f"/fake/{repo_id}/{filename}"
with (
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: files),
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
patch("huggingface_hub.try_to_load_from_cache", lambda *_a, **_k: None),
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fake_download),
):
out = backend._download_gguf(hf_repo = repo, hf_variant = "Q4_K_M")
assert downloaded == files
assert out == f"/fake/{repo}/{files[0]}"
def _siblings(items: dict[str, int]):
"""Mock ``hf_model_info(...).siblings`` payload."""
@ -315,6 +567,21 @@ class TestIterHfCacheSnapshots:
out = list(_iter_hf_cache_snapshots("unsloth/multi"))
assert [p.name for p in out] == ["b" * 40, "a" * 40]
def test_skips_snapshot_when_mtime_is_unavailable(self, hf_cache, monkeypatch):
stale = _build_cache(hf_cache, "unsloth/multi", {"x.gguf": 1}, snapshot_sha = "a" * 40)
good = _build_cache(hf_cache, "unsloth/multi", {"y.gguf": 1}, snapshot_sha = "b" * 40)
original_stat = Path.stat
def flaky_stat(self, *args, **kwargs):
if self == stale:
raise FileNotFoundError(str(self))
return original_stat(self, *args, **kwargs)
monkeypatch.setattr(Path, "stat", flaky_stat)
out = list(_iter_hf_cache_snapshots("unsloth/multi"))
assert out == [good]
def test_repo_id_match_is_case_insensitive(self, hf_cache):
_build_cache(hf_cache, "unsloth/Foo-GGUF", {"Foo-Q4_K_M.gguf": 1})
# Lookup with different org/name casing still resolves
@ -347,6 +614,87 @@ class TestListGgufVariantsFromCache:
assert _list_gguf_variants_from_hf_cache("unsloth/absent") is None
class TestCachedColocatedSplitMain:
def test_prefers_older_complete_snapshot_over_newer_partial(self, hf_cache):
# Newer snapshot has only shard 1; older snapshot has the complete set. The
# complete older snapshot must win so the split GGUF can load co-located.
shard1 = "m-00001-of-00002.gguf"
shard2 = "m-00002-of-00002.gguf"
old = _build_cache(
hf_cache, "unsloth/split-GGUF", {shard1: 100, shard2: 100}, snapshot_sha = "a" * 40
)
new = _build_cache(hf_cache, "unsloth/split-GGUF", {shard1: 100}, snapshot_sha = "b" * 40)
os.utime(old, (1000, 1000))
os.utime(new, (2000, 2000))
main = _cached_colocated_split_main("unsloth/split-GGUF", shard1, [shard2], {})
assert main is not None
assert main.startswith(str(old))
def test_returns_none_when_shards_span_snapshots(self, hf_cache):
shard1 = "m-00001-of-00002.gguf"
shard2 = "m-00002-of-00002.gguf"
a = _build_cache(hf_cache, "unsloth/split-GGUF", {shard1: 100}, snapshot_sha = "a" * 40)
b = _build_cache(hf_cache, "unsloth/split-GGUF", {shard2: 100}, snapshot_sha = "b" * 40)
os.utime(a, (1000, 1000))
os.utime(b, (2000, 2000))
assert _cached_colocated_split_main("unsloth/split-GGUF", shard1, [shard2], {}) is None
class TestResolveRepoIdCasing:
def test_maps_to_canonical_casing(self, monkeypatch):
monkeypatch.setattr(
"utils.paths.resolve_cached_repo_id_case",
lambda repo: "unsloth/Gemma-4-GGUF" if repo.lower() == "unsloth/gemma-4-gguf" else repo,
)
# A companion download passed the resolved id reads the same cache entry
# as the main GGUF instead of missing it under the requested casing.
assert _resolve_repo_id_casing("unsloth/gemma-4-gguf") == "unsloth/Gemma-4-GGUF"
def test_passthrough_on_resolver_error(self, monkeypatch):
def boom(_repo):
raise RuntimeError("resolver unavailable")
monkeypatch.setattr("utils.paths.resolve_cached_repo_id_case", boom)
assert _resolve_repo_id_casing("unsloth/gemma-4-gguf") == "unsloth/gemma-4-gguf"
def test_companion_only_newer_snapshot_does_not_shadow_real_variants(self, hf_cache):
# A newer snapshot holds only a vision projector fetched on demand,
# while the quant files live in an older snapshot. The newer snapshot
# must not shadow the real variants; the vision flag carries over.
old = _build_cache(
hf_cache,
"unsloth/vision-GGUF",
{"vision-Q4_K_M.gguf": 100},
snapshot_sha = "a" * 40,
)
new = _build_cache(
hf_cache,
"unsloth/vision-GGUF",
{"mmproj-vision-F16.gguf": 10},
snapshot_sha = "b" * 40,
)
os.utime(old, (1000, 1000))
os.utime(new, (2000, 2000))
out = _list_gguf_variants_from_hf_cache("unsloth/vision-GGUF")
assert out is not None
variants, has_vision = out
assert [v.quant for v in variants] == ["Q4_K_M"]
assert has_vision is True
def test_companion_only_cache_returns_empty_variants_with_vision(self, hf_cache):
# Only a vision projector is cached anywhere: report the vision flag
# with an empty variant list rather than None.
_build_cache(hf_cache, "unsloth/vision-GGUF", {"mmproj-vision-F16.gguf": 10})
out = _list_gguf_variants_from_hf_cache("unsloth/vision-GGUF")
assert out is not None
variants, has_vision = out
assert variants == []
assert has_vision is True
class TestListGgufVariantsOffline:
def test_offline_env_short_circuits_api(self, hf_cache, clean_offline_env, monkeypatch):
_build_cache(hf_cache, "unsloth/a", {"a-UD-Q4_K_XL.gguf": 1})

View file

@ -3037,3 +3037,60 @@ def test_acquire_swap_gate_is_cancellation_safe():
inference_route._auto_switch_process_lock.release()
asyncio.run(asyncio.wait_for(main(), timeout = 5))
def test_no_model_loaded_detail_appends_hint_only_when_off(monkeypatch):
# The "no model loaded" errors point at the opt-in auto-switch toggle so a
# request naming a listed-but-unloaded model is self-explanatory -- but only
# when it's off. With it on the name simply didn't resolve, so no hint.
base = "No GGUF model loaded. Load a GGUF model first."
monkeypatch.setattr(settings, "get_openai_auto_switch_enabled", lambda: False)
off = inference_route._no_model_loaded_detail(base)
assert off.startswith(base)
assert "Model auto-switch" in off and "Settings > API" in off
monkeypatch.setattr(settings, "get_openai_auto_switch_enabled", lambda: True)
assert inference_route._no_model_loaded_detail(base) == base
def _run_responses_stream_no_model(monkeypatch, *, enabled, active_model_name):
# Drive _responses_stream's GGUF-not-loaded guard: llama backend unloaded,
# inference backend maybe holding a non-GGUF model. Returns the 400 detail.
from fastapi import HTTPException
from models.inference import ResponsesRequest, ChatMessage
monkeypatch.setattr(settings, "get_openai_auto_switch_enabled", lambda: enabled)
monkeypatch.setattr(
inference_route, "get_llama_cpp_backend", lambda: _FakeBackend(loaded_id = None)
)
monkeypatch.setattr(
inference_route,
"get_inference_backend",
lambda: type("_B", (), {"active_model_name": active_model_name})(),
)
payload = ResponsesRequest(model = "unsloth/Qwen3.5-4B-GGUF", stream = True)
messages = [ChatMessage(role = "user", content = "hi")]
with pytest.raises(HTTPException) as exc:
asyncio.run(inference_route._responses_stream(payload, messages, None))
assert exc.value.status_code == 400
return exc.value.detail
def test_responses_stream_hint_matches_toggle_regardless_of_active_model(monkeypatch):
# Streaming /v1/responses shares the GGUF-only 400 with the other "no model
# loaded" sites, so the auto-switch hint attaches whenever the toggle is
# off -- including while a non-GGUF model is active, since auto-switch
# evicts it to load a resolved GGUF (_maybe_auto_switch_model's resolver
# branch has no active-model guard, unlike its reload-stash branch). Only
# the toggle being on suppresses it.
hinted = _run_responses_stream_no_model(monkeypatch, enabled = False, active_model_name = None)
assert "Model auto-switch" in hinted
on = _run_responses_stream_no_model(monkeypatch, enabled = True, active_model_name = None)
assert "Model auto-switch" not in on
non_gguf_loaded = _run_responses_stream_no_model(
monkeypatch, enabled = False, active_model_name = "unsloth/Llama-3.2-1B-Instruct"
)
assert "Model auto-switch" in non_gguf_loaded

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -280,6 +280,56 @@ class TestStreamHealer:
assert [c["id"] for c in calls] == ["call_0", "call_1"]
assert _events_text(events).strip() == "then"
def test_mistral_array_multiple_calls_all_promoted_in_stream(self):
# A canonical Mistral [TOOL_CALLS] array carries several calls under a
# SINGLE signal. Draining only the first call would leave the residue
# starting at ",{...}]" (no signal), so later calls in the same array
# must be promoted in the same pass, not flushed as raw text.
healer = StreamToolCallHealer({"get_weather", "get_time"})
array = (
'[TOOL_CALLS][{"name":"get_weather","arguments":{"city":"Paris"}},'
'{"name":"get_time","arguments":{"tz":"UTC"}}]'
)
events = healer.feed(array) + healer.finalize()
calls = _events_calls(events)
assert [c["function"]["name"] for c in calls] == ["get_weather", "get_time"]
assert [c["id"] for c in calls] == ["call_0", "call_1"]
assert _events_text(events) == ""
def test_mistral_array_multiple_calls_promoted_char_by_char(self):
healer = StreamToolCallHealer({"get_weather", "get_time"})
array = (
'[TOOL_CALLS][{"name":"get_weather","arguments":{"city":"Paris"}},'
'{"name":"get_time","arguments":{"tz":"UTC"}}]'
)
events = []
for ch in array:
events += healer.feed(ch)
events += healer.finalize()
calls = _events_calls(events)
assert [c["function"]["name"] for c in calls] == ["get_weather", "get_time"]
assert _events_text(events) == ""
def test_mistral_array_undeclared_middle_kept_as_text_others_promoted(self):
# A mid-array element for a tool that is not declared must survive as
# text while the declared neighbours on either side still promote in
# document order.
healer = StreamToolCallHealer({"a", "c"})
array = (
'[TOOL_CALLS][{"name":"a","arguments":{}},'
'{"name":"b","arguments":{}},{"name":"c","arguments":{}}]'
)
events = healer.feed(array) + healer.finalize()
assert [c["function"]["name"] for c in _events_calls(events)] == ["a", "c"]
assert '"b"' in _events_text(events)
def test_mistral_array_then_trailing_prose(self):
healer = StreamToolCallHealer({"a", "b"})
array = '[TOOL_CALLS][{"name":"a","arguments":{}},{"name":"b","arguments":{}}]'
events = healer.feed(f"{array} all done") + healer.finalize()
assert [c["function"]["name"] for c in _events_calls(events)] == ["a", "b"]
assert "all done" in _events_text(events)
def test_incomplete_call_healed_at_finalize(self):
healer = StreamToolCallHealer({"Bash"})
events = healer.feed('<tool_call>{"name":"Bash","arguments":{"cmd":"ls"}}')
@ -465,6 +515,7 @@ class ScriptedClient:
_url,
json = None,
timeout = None,
headers = None,
):
self.posts.append(json)
return httpx.Response(200, json = self.bodies[min(len(self.posts) - 1, len(self.bodies) - 1)])
@ -1356,3 +1407,42 @@ class TestOpenaiStreamingRoute:
assert chunks[0] == line + "\n\n" # byte-for-byte relay
asyncio.run(_run())
class TestHealerSignalAlignment:
"""The passthrough healer buffers only formats its parser can promote.
The loops' bare [ARGS] rehearsal signal is gated on active tool names
there; ungated in the healer it would stall legitimate prose until
finalization without ever producing a promotable call."""
def test_heal_signals_are_promotable_formats_only(self):
from core.inference.passthrough_healing import _HEAL_SIGNALS
assert set(_HEAL_SIGNALS) == {
"<tool_call>",
"<|tool_call>",
"<function=",
"[TOOL_CALLS]",
}
def test_prose_with_bare_args_marker_streams_through(self):
healer = StreamToolCallHealer({"Bash"})
chunks = [
"Use the pattern foo",
"[ARGS] in templates when calling tools, ",
"and remember to close it.",
]
streamed = ""
for chunk in chunks:
streamed += _events_text(healer.feed(chunk))
# Incremental relay: nothing withheld for finalize.
assert streamed == "".join(chunks)
final = healer.finalize()
assert not _events_calls(final)
assert not healer.healed
def test_bracket_tool_calls_still_promote_in_stream(self):
healer = StreamToolCallHealer({"web_search"})
events = healer.feed('[TOOL_CALLS]web_search{"query": "unsloth docs"}') + healer.finalize()
(call,) = _events_calls(events)
assert call["function"]["name"] == "web_search"
assert healer.healed

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,252 @@
# SPDX-License-Identifier: AGPL-3.0-only
"""Presence-penalty parity between the GGUF path and the safetensors/MLX paths.
The safetensors path historically dropped ``presence_penalty``, so the SAME model
looked worse served as safetensors. These tests pin the processor semantics
(subtract once per distinct completion token, prompt excluded, presence not
frequency, zero a no-op, negatives raise) plus a param-propagation regression
over route -> orchestrator cmd -> worker gen_kwargs.
"""
import threading
import pytest
import torch
from core.inference.presence_penalty import (
apply_presence_penalty,
_make_presence_penalty_processor,
)
def test_seen_token_gets_exactly_minus_penalty_unseen_unchanged():
input_ids = torch.tensor([[0, 1, 3]]) # prompt [0, 1], completion [3]
scores = torch.zeros(1, 5)
out = apply_presence_penalty(input_ids, scores, penalty = 1.5, prompt_len = 2)
assert out[0, 3].item() == pytest.approx(-1.5)
for tok in (0, 1, 2, 4):
assert out[0, tok].item() == pytest.approx(0.0)
def test_multiplicity_ignored_presence_not_frequency():
# Token 3 emitted three times -> still a single -penalty (presence, not freq).
input_ids = torch.tensor([[0, 3, 3, 3]])
scores = torch.zeros(1, 5)
out = apply_presence_penalty(input_ids, scores, penalty = 2.0, prompt_len = 1)
assert out[0, 3].item() == pytest.approx(-2.0)
def test_negative_penalty_raises_seen_logits():
input_ids = torch.tensor([[0, 2]])
scores = torch.zeros(1, 4)
out = apply_presence_penalty(input_ids, scores, penalty = -0.5, prompt_len = 1)
assert out[0, 2].item() == pytest.approx(0.5)
def test_prompt_tokens_excluded():
# Token 7 is prompt-only (untouched); token 4 in the completion is penalized.
input_ids = torch.tensor([[7, 4, 4]])
scores = torch.zeros(1, 8)
out = apply_presence_penalty(input_ids, scores, penalty = 1.0, prompt_len = 1)
assert out[0, 7].item() == pytest.approx(0.0)
assert out[0, 4].item() == pytest.approx(-1.0)
def test_batch_rows_isolated():
input_ids = torch.tensor([[0, 1], [0, 2]]) # row completions [1] and [2]
scores = torch.zeros(2, 4)
out = apply_presence_penalty(input_ids, scores, penalty = 1.0, prompt_len = 1)
assert out[0, 1].item() == pytest.approx(-1.0)
assert out[0, 2].item() == pytest.approx(0.0)
assert out[1, 2].item() == pytest.approx(-1.0)
assert out[1, 1].item() == pytest.approx(0.0)
def test_zero_penalty_is_noop():
input_ids = torch.tensor([[0, 1, 2]])
scores = torch.randn(1, 5)
original = scores.clone()
out = apply_presence_penalty(input_ids, scores, penalty = 0.0, prompt_len = 1)
assert torch.equal(out, original)
def test_empty_completion_is_noop():
# prompt_len covers the whole sequence -> nothing generated yet.
input_ids = torch.tensor([[0, 1, 2]])
scores = torch.randn(1, 5)
original = scores.clone()
out = apply_presence_penalty(input_ids, scores, penalty = 1.5, prompt_len = 3)
assert torch.equal(out, original)
def test_out_of_vocab_id_ignored():
# A generated id >= vocab_size (defensive) must not index out of bounds.
input_ids = torch.tensor([[0, 9]])
scores = torch.zeros(1, 5) # vocab 5, token 9 is out of range
out = apply_presence_penalty(input_ids, scores, penalty = 1.0, prompt_len = 1)
assert torch.equal(out, torch.zeros(1, 5))
def test_negative_generated_id_ignored():
# A negative generated id (defensive) must be dropped, not wrap to scores[-1].
input_ids = torch.tensor([[0, -1]])
scores = torch.zeros(1, 5)
out = apply_presence_penalty(input_ids, scores, penalty = 1.0, prompt_len = 1)
# Nothing penalized; in particular the last row (the numpy/torch wrap target
# for id -1) is untouched.
assert torch.equal(out, torch.zeros(1, 5))
def test_mixed_oob_negative_and_valid_ids_only_in_range_penalized():
# Completion mixes a valid id (1), an out-of-vocab id (9 >= vocab 5) and a
# negative id (-1). Only the in-range distinct id is penalized; OOB/negative
# ids are ignored with no crash and no wrong-index wrap. This fails under the
# old ``seen[seen < vocab_size]`` filter (id -1 wraps to the last row) and
# passes only with the both-ends bound.
input_ids = torch.tensor([[0, 1, 9, -1, 1]]) # prompt [0], completion [1, 9, -1, 1]
scores = torch.zeros(1, 5)
out = apply_presence_penalty(input_ids, scores, penalty = 1.0, prompt_len = 1)
expected = torch.zeros(1, 5)
expected[0, 1] = -1.0 # once per distinct in-range id (multiplicity ignored)
assert torch.equal(out, expected)
assert out[0, 4].item() == pytest.approx(0.0) # id -1 did not wrap to the last row
def test_dtype_and_device_preserved():
input_ids = torch.tensor([[0, 1]])
scores = torch.zeros(1, 4, dtype = torch.float16)
out = apply_presence_penalty(input_ids, scores, penalty = 1.0, prompt_len = 1)
assert out.dtype == torch.float16
assert out.device == scores.device
def test_processor_none_when_zero():
assert _make_presence_penalty_processor(0.0, prompt_len = 0) is None
def test_processor_applies_penalty():
proc = _make_presence_penalty_processor(1.5, prompt_len = 2)
assert proc is not None
input_ids = torch.tensor([[0, 1, 3]])
scores = torch.zeros(1, 5)
out = proc(input_ids, scores)
assert out[0, 3].item() == pytest.approx(-1.5)
def test_processor_composes_with_other_processors():
# LogitsProcessorList must run our processor alongside a pre-existing one.
from transformers import LogitsProcessor, LogitsProcessorList
class _AddToTokenZero(LogitsProcessor):
def __call__(self, input_ids, scores):
scores[:, 0] = scores[:, 0] + 100.0
return scores
presence = _make_presence_penalty_processor(1.0, prompt_len = 1)
combined = LogitsProcessorList([_AddToTokenZero(), *presence])
input_ids = torch.tensor([[5, 2]]) # completion = [2]
scores = torch.zeros(1, 6)
out = combined(input_ids, scores)
assert out[0, 0].item() == pytest.approx(100.0) # other processor ran
assert out[0, 2].item() == pytest.approx(-1.0) # presence ran
def test_mlx_presence_penalty_callable():
mx = pytest.importorskip("mlx.core", reason = "MLX only ships on arm64 macOS")
from core.inference.mlx_inference import _make_mlx_presence_penalty_processor
proc = _make_mlx_presence_penalty_processor(1.5)
# First call = prompt only (latches prompt_len, penalizes nothing).
prompt = mx.array([10, 11])
logits0 = mx.zeros((1, 20))
out0 = proc(prompt, logits0)
assert float(out0[0, 10]) == pytest.approx(0.0)
# Second call: one completion token (5) appended -> penalized once.
seq = mx.array([10, 11, 5])
logits1 = mx.zeros((1, 20))
out1 = proc(seq, logits1)
assert float(out1[0, 5]) == pytest.approx(-1.5)
assert float(out1[0, 10]) == pytest.approx(0.0) # prompt token untouched
def test_mlx_presence_penalty_bounds_out_of_range_ids():
# Documents (and, on Apple Silicon CI, enforces) the intended MLX bound:
# out-of-vocab and negative completion ids must be ignored. MLX does no
# bounds checking and OOB indexing is undefined behavior (crash / memory
# corruption), so the processor routes stray ids to a discarded scratch slot
# and penalizes only in-range distinct ids -- matching the torch filter
# seen[(seen >= 0) & (seen < vocab)]. Skips off arm64 macOS where MLX is absent.
mx = pytest.importorskip("mlx.core", reason = "MLX only ships on arm64 macOS")
from core.inference.mlx_inference import _make_mlx_presence_penalty_processor
proc = _make_mlx_presence_penalty_processor(1.0)
proc(mx.array([10, 11]), mx.zeros((1, 8))) # first call latches prompt_len = 2
# Completion appends a valid id (3), an out-of-vocab id (99 >= vocab 8) and a
# negative id (-1); only the in-range id is penalized and nothing crashes.
seq = mx.array([10, 11, 3, 99, -1])
out = proc(seq, mx.zeros((1, 8)))
assert float(out[0, 3]) == pytest.approx(-1.0)
for tok in range(8):
if tok != 3:
assert float(out[0, tok]) == pytest.approx(0.0)
# Param propagation: route payload -> orchestrator cmd -> worker gen_kwargs
_SAMPLING = {
"temperature": 0.7,
"top_p": 0.8,
"top_k": 20,
"min_p": 0.05,
"repetition_penalty": 1.1,
"presence_penalty": 1.5,
}
def test_orchestrator_cmd_carries_all_sampling_params():
from core.inference.orchestrator import InferenceOrchestrator
o = InferenceOrchestrator.__new__(InferenceOrchestrator)
cmd = o._build_generate_cmd(
"req1",
None,
messages = [{"role": "user", "content": "hi"}],
max_new_tokens = 128,
**_SAMPLING,
)
for key, val in _SAMPLING.items():
assert cmd[key] == val, f"{key} dropped/altered in orchestrator cmd"
def test_worker_forwards_all_sampling_params_to_backend():
from core.inference.worker import _handle_generate
class _RecordingBackend:
last_generation_stats = None
def __init__(self):
self.received = None
def generate_chat_response(self, **kwargs):
self.received = kwargs
return iter(()) # empty stream -> loop exits, gen_done is sent
class _FakeQueue:
def __init__(self):
self.items = []
def put(self, item):
self.items.append(item)
cmd = {
"type": "generate",
"request_id": "r",
"messages": [{"role": "user", "content": "hi"}],
"max_new_tokens": 128,
**_SAMPLING,
}
backend = _RecordingBackend()
_handle_generate(backend, cmd, _FakeQueue(), threading.Event())
assert backend.received is not None
for key, val in _SAMPLING.items():
assert backend.received[key] == val, f"{key} dropped/altered in worker gen_kwargs"

View file

@ -0,0 +1,216 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""TEMPLATE_TO_RESPONSES_MAPPER markers must match what the templates render.
The manual instruction/response markers are the fallback for
train_on_completions when auto-detection is unavailable, so a marker that
never matches the rendered chat template masks every assistant token and the
run dies on the all-labels-masked safety net. Six template families shipped
such markers:
mistral - "[INST] " / " [/INST]": the surrounding spaces fold into
the neighbouring tokens ("[INST]" is a single special
token in Mistral v0.3), so the padded strings never match.
llama - same space folding, plus llama-2 tokenizes [INST] after
<s> as bare "[" on transformers 5.x while the standalone
encoding gives "▁[", so the marker must anchor on <s>.
starling - trailing space after "GPT4 Correct Assistant:" folds
into the next content token ("▁Hello").
glm - "[gMASK]<sop>" renders once at text start, never before
later user turns; "<think>" is generation scaffolding
that non-final turns render as a lone "</think>".
qwen3-thinking - "<think>" is stripped from non-final assistant turns
(Qwen3-Thinking-2507) or never rendered (QwQ).
zephyr - role tags are plain text, and SentencePiece tokenizes
"<|assistant|>" differently at text start than after
"</s>\\n" mid-conversation; the markers need the leading
newline anchor to tokenize like a real turn boundary.
Literal assertions run everywhere; the token-level masking checks need the
representative tokenizers plus unsloth_zoo and skip when either is
unavailable (offline CI).
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# model_mappings is dependency-free: load it directly so these tests run
# without the studio venv / package import side effects.
_MM_PATH = Path(_BACKEND_DIR) / "utils" / "datasets" / "model_mappings.py"
_mm_spec = importlib.util.spec_from_file_location("_marker_test_mm", _MM_PATH)
model_mappings = importlib.util.module_from_spec(_mm_spec)
_mm_spec.loader.exec_module(model_mappings)
T2R = model_mappings.TEMPLATE_TO_RESPONSES_MAPPER
# ── Fixed entries: markers derived from what each representative tokenizer
# actually renders (see PR for the token-level derivation). ──
EXPECTED_FIXED = {
"mistral": {"instruction": "[INST]", "response": "[/INST]"},
"llama": {"instruction": "<s>[INST]", "response": "[/INST]"},
"starling": {"instruction": "GPT4 Correct User:", "response": "GPT4 Correct Assistant:"},
"glm": {"instruction": "<|user|>", "response": "<|assistant|>"},
"qwen3-thinking": {"instruction": "<|im_start|>user\n", "response": "<|im_start|>assistant\n"},
"zephyr": {"instruction": "\n<|user|>\n", "response": "\n<|assistant|>\n"},
}
# Spot-pin some known-good entries so a refactor cannot silently change them.
EXPECTED_UNCHANGED = {
"qwen3": {"instruction": "<|im_start|>user\n", "response": "<|im_start|>assistant\n"},
"llama-3.1": {
"instruction": "<|start_header_id|>user<|end_header_id|>\n\n",
"response": "<|start_header_id|>assistant<|end_header_id|>\n\n",
},
"phi-4": {
"instruction": "<|im_start|>user<|im_sep|>",
"response": "<|im_start|>assistant<|im_sep|>",
},
"gemma-3": {"instruction": "<start_of_turn>user\n", "response": "<start_of_turn>model\n"},
"gpt-oss": {
"instruction": "<|start|>user<|message|>",
"response": "<|start|>assistant<|channel|>final<|message|>",
},
}
@pytest.mark.parametrize("template", sorted(EXPECTED_FIXED))
def test_fixed_marker_literals(template):
assert T2R[template] == EXPECTED_FIXED[template]
@pytest.mark.parametrize("template", sorted(EXPECTED_UNCHANGED))
def test_unchanged_marker_literals(template):
assert T2R[template] == EXPECTED_UNCHANGED[template]
def test_no_marker_is_empty_or_whitespace():
for template, parts in T2R.items():
assert parts["instruction"].strip(), template
assert parts["response"].strip(), template
# ── Token-level checks: markers must select exactly the assistant turns on a
# rendered two-turn fixture, and the final EOS label must never be -100. ──
REPRESENTATIVES = {
"mistral": ["unsloth/mistral-7b-instruct-v0.3"],
"llama": ["unsloth/llama-2-7b-chat"],
"starling": ["unsloth/Starling-LM-7B-beta"],
"glm": ["unsloth/GLM-4.7-Flash"],
"qwen3-thinking": ["unsloth/Qwen3-4B-Thinking-2507", "Qwen/QwQ-32B"],
"zephyr": ["unsloth/zephyr-sft"],
}
FIXTURE = [
{"role": "user", "content": "zebra alpha question one?"},
{"role": "assistant", "content": "grape reply number one."},
{"role": "user", "content": "zebra beta question two?"},
{"role": "assistant", "content": "grape reply number two."},
]
def _load_tokenizer(repo):
try:
from transformers import AutoTokenizer
except Exception as e: # pragma: no cover
pytest.skip(f"transformers unavailable: {e}")
try:
return AutoTokenizer.from_pretrained(repo)
except OSError as e:
pytest.skip(f"tokenizer {repo} unavailable (offline?): {e}")
except Exception:
# Tokenizer class newer than this transformers (e.g. GLM-4.7's
# TokenizersBackend): build directly from tokenizer.json.
try:
import json as _json
from huggingface_hub import hf_hub_download
from transformers import PreTrainedTokenizerFast
with open(hf_hub_download(repo, "tokenizer_config.json"), encoding = "utf-8") as f:
cfg = _json.load(f)
tok_file = hf_hub_download(repo, "tokenizer.json")
def _tokval(v):
return v["content"] if isinstance(v, dict) else v
return PreTrainedTokenizerFast(
tokenizer_file = tok_file,
chat_template = cfg.get("chat_template"),
**{
k: _tokval(cfg[k])
for k in ("bos_token", "eos_token", "pad_token", "unk_token")
if cfg.get(k) is not None
},
)
except Exception as e:
pytest.skip(f"tokenizer {repo} unavailable (offline?): {e}")
def _train_on_responses_only():
try:
from unsloth_zoo.dataset_utils import train_on_responses_only
except Exception as e:
pytest.skip(f"unsloth_zoo unavailable: {e}")
return train_on_responses_only
@pytest.mark.parametrize(
"template,repo",
[(t, r) for t, repos in sorted(REPRESENTATIVES.items()) for r in repos],
)
def test_fixed_markers_token_level(template, repo):
tor = _train_on_responses_only()
tok = _load_tokenizer(repo)
parts = T2R[template]
msgs = [{"role": "system", "content": "You are a terse assistant."}] + FIXTURE
try:
ids = tok.apply_chat_template(msgs, tokenize = True, add_generation_prompt = False)
if hasattr(ids, "keys"):
ids = ids["input_ids"] # transformers 5.x returns a BatchEncoding
except Exception:
ids = tok.apply_chat_template(FIXTURE, tokenize = True, add_generation_prompt = False)
if hasattr(ids, "keys"):
ids = ids["input_ids"]
fn = tor(
None,
instruction_part = parts["instruction"],
response_part = parts["response"],
tokenizer = tok,
return_function = True,
)
labels = fn({"input_ids": [list(ids)]})["labels"][0]
n = len(ids)
trained = tok.decode([ids[i] for i in range(n) if labels[i] != -100])
masked = tok.decode([ids[i] for i in range(n) if labels[i] == -100])
# User and system content fully masked
assert "question one" not in trained and "question one" in masked
assert "question two" not in trained and "question two" in masked
assert "terse assistant" not in trained
# EVERY assistant turn trained, not just the last
assert "reply number one" in trained
assert "reply number two" in trained
# The final EOS (last non-whitespace token) must never be -100, or the
# fine-tuned model never learns to stop generating.
i = n - 1
while i > 0 and tok.decode([ids[i]]).strip() == "":
i -= 1
assert labels[i] != -100, f"final token {tok.convert_ids_to_tokens(int(ids[i]))!r} is masked"
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))

View file

@ -59,6 +59,7 @@ from models.inference import (
ResponsesUsage,
)
from routes.inference import (
_ResponsesReasoningExtractor,
_SameTaskStreamingResponse,
_build_chat_request,
_chat_tool_calls_to_responses_output,
@ -795,6 +796,7 @@ class TestResponsesNonStreamingAdapter:
def test_monitor_records_translated_visible_text(self, monkeypatch):
import routes.inference as inf_mod
import routes.inference as inf_mod
async def fake_chat_completions(chat_req, request):
assert request.state.skip_api_monitor is True
@ -1988,6 +1990,123 @@ class TestTranslatedMessagesValidate:
ChatMessage(**m.model_dump(exclude_none = True))
# reasoning_prefilled: enable_thinking templates prefill an unclosed <think>, so
# generation begins inside the block; the extractor must start in reasoning.
class TestReasoningPrefilledExtractor:
def test_prefilled_single_feed_splits_lone_close(self):
# T1: reasoning...</think>answer with a prefilled (unseen) open tag.
reasoning, visible = _extract_responses_reasoning(
"plan</think>answer",
parse_think_markers = True,
reasoning_prefilled = True,
)
assert reasoning == "plan"
assert visible == "answer"
def test_prefilled_never_closed_is_all_reasoning(self):
# T2: truncated mid-thought (no </think>) -> all reasoning (GGUF parity).
reasoning, visible = _extract_responses_reasoning(
"still thinking with no close",
parse_think_markers = True,
reasoning_prefilled = True,
)
assert reasoning == "still thinking with no close"
assert visible == ""
def test_prefilled_close_split_across_feeds(self):
# T3: </think> straddles two feed() calls; holdback resolves it.
ex = _ResponsesReasoningExtractor(parse_think_markers = True, reasoning_prefilled = True)
r1, v1 = ex.feed("plan</th")
r2, v2 = ex.feed("ink>ans")
fr, fv = ex.finish()
assert (r1 + r2 + fr) == "plan"
assert (v1 + v2 + fv) == "ans"
def test_prefilled_close_split_one_char_per_feed(self):
# T4: every char in its own feed still splits correctly.
ex = _ResponsesReasoningExtractor(parse_think_markers = True, reasoning_prefilled = True)
reasoning, visible = "", ""
for ch in "plan</think>x":
r, v = ex.feed(ch)
reasoning += r
visible += v
fr, fv = ex.finish()
assert (reasoning + fr) == "plan"
assert (visible + fv) == "x"
def test_prefilled_empty_generation(self):
# T5: nothing generated.
reasoning, visible = _extract_responses_reasoning(
"",
parse_think_markers = True,
reasoning_prefilled = True,
)
assert reasoning == ""
assert visible == ""
def test_prefilled_whitespace_after_close_is_visible(self):
# T6: Qwen commonly emits </think>\n\n before the answer.
reasoning, visible = _extract_responses_reasoning(
"plan</think>\n\nanswer",
parse_think_markers = True,
reasoning_prefilled = True,
)
assert reasoning == "plan"
assert visible == "\n\nanswer"
def test_prefilled_stray_open_tag_is_suppressed(self):
# T7: a re-emitted literal <think> inside prefilled reasoning is dropped,
# not leaked into the drawer (covers enable_thinking_effort full-tag output).
reasoning, visible = _extract_responses_reasoning(
"a<think>b</think>c",
parse_think_markers = True,
reasoning_prefilled = True,
)
assert reasoning == "ab"
assert visible == "c"
assert "<think>" not in reasoning
def test_prefilled_close_at_start_empty_reasoning(self):
# T8: model closed immediately (empty reasoning) then answered.
reasoning, visible = _extract_responses_reasoning(
"</think>hi",
parse_think_markers = True,
reasoning_prefilled = True,
)
assert reasoning == ""
assert visible == "hi"
def test_not_prefilled_lone_close_preserves_current_behavior(self):
# T9: without prefilled, a lone close tag keeps the pre-fix behavior (parity guard).
reasoning, visible = _extract_responses_reasoning(
"reasoning</think>ans",
parse_think_markers = True,
reasoning_prefilled = False,
)
assert reasoning == ""
assert visible == "reasoningans"
def test_not_prefilled_full_pair_still_splits(self):
# T10: normal explicit <think>..</think> (GGUF / Harmony) unchanged.
reasoning, visible = _extract_responses_reasoning(
"<think>r</think>v",
parse_think_markers = True,
reasoning_prefilled = False,
)
assert reasoning == "r"
assert visible == "v"
def test_prefilled_ignored_when_markers_not_parsed(self):
# T11: a non-reasoning model passes text through even with reasoning_prefilled False.
reasoning, visible = _extract_responses_reasoning(
"just an answer",
parse_think_markers = False,
reasoning_prefilled = False,
)
assert reasoning == ""
assert visible == "just an answer"
# =====================================================================
# Streaming passthrough healing — text-form calls promoted in order
# =====================================================================

View file

@ -11,6 +11,8 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
_backend_root = Path(__file__).resolve().parent.parent
if str(_backend_root) not in sys.path:
sys.path.insert(0, str(_backend_root))
@ -46,6 +48,21 @@ reasoning_effort: {{ reasoning_effort }}
"""
# DeepSeek-V4-Flash: an enable_thinking on/off gate PLUS a reasoning_effort
# 'max' preamble. The shipped template only *branches* on 'max' ('high' renders
# identically to thinking-on-without-the-preamble), so the literal scan alone
# would surface only ['max']; the classifier adds 'high' for deepseek-v4 to
# expose the encoder's full none/high/max ladder.
DEEPSEEK_V4_TEMPLATE = (
"{%- if not thinking is defined %}"
"{%- if enable_thinking is defined %}{%- set thinking = enable_thinking %}"
"{%- else %}{%- set thinking = false %}{%- endif %}{%- endif %}\n"
"{%- if thinking and reasoning_effort == 'max' %}"
"{{- 'Reasoning Effort: Absolute maximum' }}{%- endif %}\n"
"{%- for message in messages %}{{- message.content }}{%- endfor %}"
)
PLAIN_TEMPLATE = """
{%- for message in messages %}
{{- message.role + ': ' + message.content + '\\n' }}
@ -88,6 +105,29 @@ def test_detect_reasoning_flags_none_template_returns_all_false():
assert flags["reasoning_style"] == "enable_thinking"
def test_detect_reasoning_flags_deepseek_v4_exposes_none_high_max():
"""DeepSeek-V4-Flash: enable_thinking gate + reasoning_effort 'max' preamble.
Classified as the hybrid style with the full none/high/max ladder even
though the template only branches on 'max'."""
from core.inference.llama_cpp import detect_reasoning_flags
flags = detect_reasoning_flags(DEEPSEEK_V4_TEMPLATE, "unsloth/DeepSeek-V4-Flash-GGUF")
assert flags["supports_reasoning"] is True
assert flags["reasoning_style"] == "enable_thinking_effort"
assert flags["reasoning_effort_levels"] == ["high", "max"]
assert flags["reasoning_always_on"] is False
def test_detect_reasoning_flags_non_deepseek_v4_effort_only_max_not_injected():
"""The 'high' injection is scoped to deepseek-v4: a different model whose
template only branches on 'max' keeps ['max'] (no phantom 'high')."""
from core.inference.llama_cpp import detect_reasoning_flags
flags = detect_reasoning_flags(DEEPSEEK_V4_TEMPLATE, "vendor/OtherHybrid-GGUF")
assert flags["reasoning_style"] == "enable_thinking_effort"
assert flags["reasoning_effort_levels"] == ["max"]
def test_detect_safetensors_features_passes_template_through_to_classifier():
"""Route wrapper forwards a real template to the inner classifier."""
from routes.inference import _detect_safetensors_features
@ -127,9 +167,8 @@ def test_detect_safetensors_features_gptoss_disables_tools():
assert flags["supports_tools"] is False
# Llama-3 / Mistral advertise tools but emit <|python_tag|> / [TOOL_CALLS],
# which our parser can't read. The route helper must not flip supports_tools=True
# for them, else the UI enables a pill the agentic loop can't honour.
# Llama-3 / Mistral / Gemma 4 tool-call formats are now parser-supported, so supports_tools=True
# must hold for all of them; only templates matching none of the five known markers are suppressed.
LLAMA3_TEMPLATE = """
{%- if tools %}
@ -161,27 +200,188 @@ MISTRAL_TEMPLATE = """
{%- endfor %}
"""
GEMMA4_TEMPLATE = """
{%- if tools %}
{{- 'Tools available. Emit calls as ' }}
{{- '<|tool_call>call:NAME{key:<|"|>val<|"|>}<tool_call|>' }}
{%- for tool in tools %}
{{- tool | tojson }}
{%- endfor %}
{%- endif %}
"""
def test_detect_safetensors_features_llama3_template_suppresses_tools():
"""Llama-3 emits <|python_tag|>; safetensors loop cannot parse it."""
def test_detect_safetensors_features_llama3_template_keeps_tools_on():
"""Llama-3 emits <|python_tag|>; parser now supports it."""
from routes.inference import _detect_safetensors_features
backend = SimpleNamespace(active_model_name = "unsloth/Llama-3.2-3B-Instruct")
flags = _detect_safetensors_features(backend, LLAMA3_TEMPLATE)
assert flags["supports_tools"] is False
assert flags["supports_tools"] is True
def test_detect_safetensors_features_mistral_template_suppresses_tools():
"""Mistral emits [TOOL_CALLS]; safetensors loop cannot parse it."""
def test_detect_safetensors_features_mistral_template_keeps_tools_on():
"""Mistral emits [TOOL_CALLS]name{json}, which the safetensors loop now parses
(the shared bracket-tag parser). The gate must no longer suppress it, or the
PR's Mistral tool support is unreachable through normal capability detection."""
from routes.inference import _detect_safetensors_features
backend = SimpleNamespace(active_model_name = "unsloth/mistral-7b-instruct-v0.3")
flags = _detect_safetensors_features(backend, MISTRAL_TEMPLATE)
assert flags["supports_tools"] is True
def test_detect_safetensors_features_gemma4_template_keeps_tools_on():
"""Gemma 4 emits <|tool_call>; parser now supports it."""
from routes.inference import _detect_safetensors_features
backend = SimpleNamespace(active_model_name = "unsloth/gemma-4-E2B-it-UD-MLX-4bit")
flags = _detect_safetensors_features(backend, GEMMA4_TEMPLATE)
assert flags["supports_tools"] is True
# DeepSeek V3 / V3.1 / R1 emit ``<tool▁calls▁begin>...`` blocks.
# Note the full-width pipe (U+FF5C) and lower-1/8-block (U+2581).
DEEPSEEK_TEMPLATE = """
{%- if tools %}
{%- for tool in tools %}
{{- tool | tojson }}
{%- endfor %}
{%- endif %}
{%- for message in messages %}
{%- if message.role == 'assistant' and message.tool_calls %}
{%- for tc in message.tool_calls %}
{{- '<tool▁calls▁begin><tool▁call▁begin>' + tc.function.name +
'<tool▁sep>' + tc.function.arguments + '<tool▁call▁end>' }}
{%- endfor %}
{%- endif %}
{%- endfor %}
"""
def test_detect_safetensors_features_deepseek_template_keeps_tools_on():
"""DeepSeek emits ``<tool▁calls▁begin>...``; parser now supports it."""
from routes.inference import _detect_safetensors_features
backend = SimpleNamespace(active_model_name = "unsloth/DeepSeek-V3.1")
flags = _detect_safetensors_features(backend, DEEPSEEK_TEMPLATE)
assert flags["supports_tools"] is True
# GLM 4.5 / 4.6 / 4.7 emit ``<tool_call>NAME\n<arg_key>...<arg_value>...
GLM_TEMPLATE = """
{%- if tools %}
For each function call, output the function name and arguments within
the following XML format:
<tool_call>{function-name}
<arg_key>{arg-key}</arg_key>
<arg_value>{arg-value}</arg_value>
</tool_call>
{%- for tool in tools %}
{{- tool | tojson }}
{%- endfor %}
{%- endif %}
"""
def test_detect_safetensors_features_glm_template_keeps_tools_on():
"""GLM 4.x emits ``<tool_call>NAME\\n<arg_key>...``; parser handles it."""
from routes.inference import _detect_safetensors_features
backend = SimpleNamespace(active_model_name = "unsloth/GLM-4.6")
flags = _detect_safetensors_features(backend, GLM_TEMPLATE)
assert flags["supports_tools"] is True
# Kimi K2 / Moonshot uses ``<|tool_calls_section_begin|>...`` blocks
# with ``functions.NAME:IDX`` as the per-call id.
KIMI_TEMPLATE = """
{%- if tools %}
<|im_system|>tool_declare<|im_middle|>{{ tools | tojson }}<|im_end|>
{%- endif %}
{%- for message in messages %}
{%- if message.role == 'assistant' and message.tool_calls %}
<|tool_calls_section_begin|>
{%- for tc in message.tool_calls %}
<|tool_call_begin|>{{ tc.id }}<|tool_call_argument_begin|>{{ tc.function.arguments | tojson }}<|tool_call_end|>
{%- endfor %}
<|tool_calls_section_end|>
{%- endif %}
{%- endfor %}
"""
def test_detect_safetensors_features_kimi_template_keeps_tools_on():
"""Kimi K2 emits ``<|tool_calls_section_begin|>...``; parser handles it."""
from routes.inference import _detect_safetensors_features
backend = SimpleNamespace(active_model_name = "unsloth/Kimi-K2-Instruct")
flags = _detect_safetensors_features(backend, KIMI_TEMPLATE)
assert flags["supports_tools"] is True
LLAMA3_2_BARE_JSON_TEMPLATE = """
{%- if tools %}
{{- 'Given the following functions, respond with JSON for a function call.' }}
{{- 'Respond in the format {"name": function name, "parameters": dictionary}.' }}
{%- for tool in tools %}
{{- tool | tojson }}
{%- endfor %}
{%- endif %}
{%- for message in messages %}
{%- if 'tool_calls' in message %}
{{- '{"name": "' + message.tool_calls[0].function.name + '", '}}
{{- '"parameters": ' + (message.tool_calls[0].function.arguments | tojson) + '}' }}
{%- endif %}
{%- endfor %}
"""
def test_detect_safetensors_features_llama3_2_bare_json_keeps_tools_on():
"""Llama-3.2 bare JSON is supported, so the pill stays enabled."""
from routes.inference import _detect_safetensors_features
backend = SimpleNamespace(active_model_name = "unsloth/Llama-3.2-3B-Instruct")
flags = _detect_safetensors_features(backend, LLAMA3_2_BARE_JSON_TEMPLATE)
assert flags["supports_tools"] is True
MINICPM5_ATTRIBUTE_TEMPLATE = """
{%- if tools %}
{{- 'Available tools. Emit calls as ' }}
{{- '<function name="NAME"><parameter name="key">value</parameter></function>' }}
{%- for tool in tools %}
{{- tool | tojson }}
{%- endfor %}
{%- endif %}
"""
def test_detect_safetensors_features_attribute_function_form_keeps_tools_on():
"""The attribute form ``<function name="...">`` must be whitelisted or the pill is wrongly suppressed."""
from routes.inference import _detect_safetensors_features
backend = SimpleNamespace(active_model_name = "openbmb/MiniCPM-5")
flags = _detect_safetensors_features(backend, MINICPM5_ATTRIBUTE_TEMPLATE)
assert flags["supports_tools"] is True
def test_detect_safetensors_features_unknown_format_suppresses_tools():
"""Tools advertised with no known marker must be suppressed."""
from routes.inference import _detect_safetensors_features
tpl = (
"{%- if tools %}<|im_start|>system\n"
"Emit tool calls as JSON-RPC notifications inside the response."
"<|im_end|>{%- endif %}"
)
backend = SimpleNamespace(active_model_name = "custom/unknown-tool-format")
flags = _detect_safetensors_features(backend, tpl)
assert flags["supports_tools"] is False
def test_detect_safetensors_features_qwen_tool_call_keeps_tools_on():
"""Sanity check: gate only suppresses non-Qwen formats."""
"""Sanity check: Qwen <tool_call> marker still flips supports_tools."""
from routes.inference import _detect_safetensors_features
backend = SimpleNamespace(active_model_name = "unsloth/Qwen3-0.6B")
@ -454,3 +654,184 @@ def test_route_layer_emits_supports_tools_true_for_qwen3_safetensors():
assert flags["supports_tools"] is True
assert flags["supports_reasoning"] is True
assert flags["supports_preserve_thinking"] is True
@pytest.mark.parametrize(
"opener",
[
"<tool▁calls▁begin>", # canonical
"<tool_calls_begin>", # ASCII underscores
"<tool▁calls>", # short form
"<tool calls begin>", # spaces
"<tool\\_calls\\_begin>", # escaped underscores
],
)
def test_detect_safetensors_features_deepseek_opener_variants_keep_tools_on(opener):
# Every DeepSeek opener the parser accepts must keep supports_tools on; the route gate derives
# its markers from the parser's TOOL_XML_SIGNALS so it can no longer drift behind the parser ...
from routes.inference import _detect_safetensors_features
tpl = (
"{%- if tools %}tools{%- endif %}"
+ opener
+ "<tool▁call▁begin>function<tool▁sep>get_time{}"
"<tool▁call▁end><tool▁calls▁end>"
)
backend = SimpleNamespace(active_model_name = "unsloth/DeepSeek-V3.1")
flags = _detect_safetensors_features(backend, tpl)
assert flags["supports_tools"] is True
# Templates that advertise tools ({%- if tools %}) and prompt the bare-JSON
# call form, but whose ``{"name":`` example is pretty-printed or JSON-escaped.
_WHITESPACE_BARE_JSON_TEMPLATE = (
"{%- if tools %}\n"
"To call a tool, output JSON of the form:\n"
'{ "name" : "function_name", "parameters": { } }\n'
"{%- endif %}\n"
"{{ messages }}"
)
_ESCAPED_BARE_JSON_TEMPLATE = (
"{%- if tools %}\n"
'Respond with {\\"name\\": \\"fn\\", \\"parameters\\": {}}\n'
"{%- endif %}\n"
"{{ messages }}"
)
_TOOLS_ADVERTISED_NO_PARSEABLE_FORM = (
"{%- if tools %}\nYou may use the available tools.\n{%- endif %}\n{{ messages }}"
)
def test_detect_safetensors_features_keeps_tools_for_pretty_printed_bare_json():
# A pretty-printed bare-JSON example (``{ "name" :``) must keep supports_tools since the parser
# accepts that whitespace via raw_decode.
from routes.inference import _detect_safetensors_features
backend = SimpleNamespace(active_model_name = "unsloth/Llama-3.2-3B-Instruct")
flags = _detect_safetensors_features(backend, _WHITESPACE_BARE_JSON_TEMPLATE)
assert flags["supports_tools"] is True
def test_detect_safetensors_features_keeps_tools_for_escaped_bare_json():
from routes.inference import _detect_safetensors_features
backend = SimpleNamespace(active_model_name = "unsloth/Llama-3.2-3B-Instruct")
flags = _detect_safetensors_features(backend, _ESCAPED_BARE_JSON_TEMPLATE)
assert flags["supports_tools"] is True
def test_detect_safetensors_features_drops_tools_when_no_parseable_form():
# Negative control: tools advertised but no parser-recognised emission form at
# all -> the pill is still dropped (the gate is not now matching everything).
from routes.inference import _detect_safetensors_features
backend = SimpleNamespace(active_model_name = "unsloth/Llama-3.2-3B-Instruct")
flags = _detect_safetensors_features(backend, _TOOLS_ADVERTISED_NO_PARSEABLE_FORM)
assert flags["supports_tools"] is False
def test_detect_safetensors_features_keeps_tools_for_function_alias_bare_json():
# A template documenting the parser-supported {"function":...} bare-JSON alias
# must keep supports_tools, mirroring the {"name":...} form.
from routes.inference import _detect_safetensors_features
tpl = (
"{%- if tools %}\n"
'Respond with {"function": "fn", "parameters": {}}\n'
"{%- endif %}\n"
"{{ messages }}"
)
backend = SimpleNamespace(active_model_name = "unsloth/Llama-3.2-3B-Instruct")
flags = _detect_safetensors_features(backend, tpl)
assert flags["supports_tools"] is True
# _sf_reasoning_prefill_mode gates the prefilled-<think> extractor (GGUF reasoning parity).
class TestSafetensorsReasoningPrefillGate:
# A minimal Qwen3-style template with the standard <think>/</think> markers.
_QWEN_TPL = "{% if enable_thinking %}<think>{% endif %}...</think>..."
# gemma-style bespoke reasoning channel -- no standard markers.
_GEMMA_TPL = "{% if enable_thinking %}<|think|>{% endif %}<|channel>thought<channel|>"
# always-on template whose GENERATION PROMPT opens an unclosed <think> (DeepSeek-R1 / QwQ /
# Qwen3-Thinking shape): the model emits only the closing </think>, so prefill.
_ALWAYS_ON_OPEN_TPL = (
"{% for m in messages %}{{ m['content'] }}{% endfor %}"
"{% if add_generation_prompt %}<|assistant|><think>\n{% endif %}"
)
# always-on template that renders PAST assistant <think>...</think> history but leaves the
# generation prompt open with no <think> (Kimi-K2-Thinking shape): the model self-emits its
# own block, so prefill mode would blank a normal answer.
_ALWAYS_ON_HISTORY_TPL = (
"{% for m in messages %}"
"{% if m['role'] == 'assistant' %}<think>{{ m.get('reasoning_content', '') }}</think>"
"{{ m['content'] }}{% endif %}"
"{% endfor %}"
"{% if add_generation_prompt %}<|im_assistant|>assistant<|im_middle|>{% endif %}"
)
def _features(self, **over):
base = {
"supports_reasoning": True,
"reasoning_always_on": False,
"reasoning_style": "enable_thinking",
}
base.update(over)
return base
def test_g1_enable_thinking_true(self):
# G1: Qwen3.5 template + explicit enable_thinking=True -> prefilled.
from routes.inference import _sf_reasoning_prefill_mode
assert _sf_reasoning_prefill_mode(self._features(), True, self._QWEN_TPL) is True
def test_g2_enable_thinking_none_defaults_on(self):
# G2: default request (None) -> prefilled (Qwen3/GLM templates default on).
from routes.inference import _sf_reasoning_prefill_mode
assert _sf_reasoning_prefill_mode(self._features(), None, self._QWEN_TPL) is True
def test_g3_enable_thinking_false(self):
# G3: thinking explicitly off -> not prefilled.
from routes.inference import _sf_reasoning_prefill_mode
assert _sf_reasoning_prefill_mode(self._features(), False, self._QWEN_TPL) is False
def test_g4_gpt_oss_reasoning_effort_excluded(self):
# G4: gpt-oss uses explicit tags via HarmonyTextStreamer -> normal mode.
from routes.inference import _sf_reasoning_prefill_mode
feats = self._features(reasoning_style = "reasoning_effort")
assert _sf_reasoning_prefill_mode(feats, True, self._QWEN_TPL) is False
def test_g5_enable_thinking_effort_included(self):
# G5: GLM-style enable_thinking_effort also prefills.
from routes.inference import _sf_reasoning_prefill_mode
feats = self._features(reasoning_style = "enable_thinking_effort")
assert _sf_reasoning_prefill_mode(feats, None, self._QWEN_TPL) is True
def test_g6_non_reasoning_model(self):
# G6: no reasoning capability -> never prefilled.
from routes.inference import _sf_reasoning_prefill_mode
feats = self._features(supports_reasoning = False, reasoning_style = None)
assert _sf_reasoning_prefill_mode(feats, True, self._QWEN_TPL) is False
def test_g7_reasoning_always_on_prompt_opens_think(self):
# G7: always-on template whose generation prompt opens <think> -> prefilled regardless of the flag.
from routes.inference import _sf_reasoning_prefill_mode
feats = self._features(reasoning_always_on = True)
assert _sf_reasoning_prefill_mode(feats, False, self._ALWAYS_ON_OPEN_TPL) is True
def test_g7b_reasoning_always_on_history_only_not_prefilled(self):
# G7b (#5704): always-on classification from rendered assistant HISTORY <think></think>
# (Kimi-K2-Thinking) whose generation prompt opens no <think>. Prefill mode would capture a
# normal answer entirely as reasoning_content and blank the visible answer, so it must be off.
from routes.inference import _sf_reasoning_prefill_mode
feats = self._features(reasoning_always_on = True)
assert _sf_reasoning_prefill_mode(feats, None, self._ALWAYS_ON_HISTORY_TPL) is False
def test_g8_gemma_bespoke_channel_excluded(self):
# G8: gemma's <|think|>/<|channel> format has no </think> -> NOT prefilled
# (would otherwise swallow the whole answer as reasoning). Regression guard.
from routes.inference import _sf_reasoning_prefill_mode
assert _sf_reasoning_prefill_mode(self._features(), True, self._GEMMA_TPL) is False
def test_g9_missing_template_not_prefilled(self):
# G9: no template available -> conservative (not prefilled).
from routes.inference import _sf_reasoning_prefill_mode
assert _sf_reasoning_prefill_mode(self._features(), True, None) is False

View file

@ -0,0 +1,217 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Safetensors/MLX reasoning-block parity with GGUF.
enable_thinking templates (Qwen3/GLM) prefill an unclosed ``<think>`` so the model
emits only the closing ``</think>`` then the answer; the safetensors stream must
split the leading text into ``reasoning_content`` deltas (plain stream and tool
loop), resetting per turn and appending only visible text to the monitor. Replays a
copy of ``sf_tool_stream``'s reasoning loop against synthetic events.
"""
from __future__ import annotations
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 routes.inference import (
_ResponsesReasoningExtractor,
_sf_reasoning_prefill_mode,
_strip_tool_xml_for_display,
)
_THINK_TPL = "...<think>...</think>..."
_ETHINK = {"reasoning_style": "enable_thinking", "supports_reasoning": True}
_ETHINK_EFFORT = {"reasoning_style": "enable_thinking_effort", "supports_reasoning": True}
def test_prefill_mode_on_for_enable_thinking_default():
assert _sf_reasoning_prefill_mode(_ETHINK, None, _THINK_TPL) is True
def test_prefill_mode_off_when_thinking_disabled():
assert _sf_reasoning_prefill_mode(_ETHINK, False, _THINK_TPL) is False
def test_prefill_mode_off_for_reasoning_effort_none():
# enable_thinking_effort turns thinking off via reasoning_effort="none"; prefilled mode
# would capture the whole answer as reasoning_content.
assert (
_sf_reasoning_prefill_mode(_ETHINK_EFFORT, None, _THINK_TPL, reasoning_effort = "none")
is False
)
assert (
_sf_reasoning_prefill_mode(_ETHINK_EFFORT, None, _THINK_TPL, reasoning_effort = "high")
is True
)
def test_prefill_mode_off_without_think_markers():
assert _sf_reasoning_prefill_mode(_ETHINK, None, "no markers here") is False
def _replay_sf_reasoning_stream(events: list[dict], *, prefilled: bool) -> dict:
"""Mirror sf_tool_stream's reasoning loop: diff each cumulative ``content``
snapshot, feed the delta through the extractor, and reset (flushing first) on
``tool_start`` / empty ``status`` so each turn splits independently."""
prev_text = ""
extractor = _ResponsesReasoningExtractor(
parse_think_markers = True, reasoning_prefilled = prefilled
)
reasoning_deltas: list[str] = []
visible_deltas: list[str] = []
monitor: list[str] = []
tool_starts: list[dict] = []
order: list[str] = [] # sequence of ("reasoning"|"visible"|"tool_start") events
def _flush():
fr, fv = extractor.finish()
if fr:
reasoning_deltas.append(fr)
order.append("reasoning")
if fv:
visible_deltas.append(fv)
monitor.append(fv)
order.append("visible")
for event in events:
etype = event["type"]
if etype == "status":
if not event["text"]:
_flush()
prev_text = ""
extractor = _ResponsesReasoningExtractor(
parse_think_markers = True, reasoning_prefilled = prefilled
)
continue
if etype in ("tool_start", "tool_end"):
if etype == "tool_start":
_flush()
prev_text = ""
extractor = _ResponsesReasoningExtractor(
parse_think_markers = True, reasoning_prefilled = prefilled
)
tool_starts.append(event)
order.append("tool_start")
continue
clean = _strip_tool_xml_for_display(event.get("text", ""), auto_heal_tool_calls = True)
new_text = clean[len(prev_text) :]
prev_text = clean
if not new_text:
continue
r, v = extractor.feed(new_text)
if r:
reasoning_deltas.append(r)
order.append("reasoning")
if v:
visible_deltas.append(v)
monitor.append(v)
order.append("visible")
_flush()
return {
"reasoning": "".join(reasoning_deltas),
"visible": "".join(visible_deltas),
"monitor": "".join(monitor),
"tool_starts": tool_starts,
"order": order,
}
def test_s1_plain_stream_splits_prefilled_reasoning():
# S1: plain/MLX single turn -> reasoning delta + visible delta; monitor visible-only.
events = [
{"type": "content", "text": "Let me compute 17*23"},
{"type": "content", "text": "Let me compute 17*23 = 391</think>The answer is 391."},
]
out = _replay_sf_reasoning_stream(events, prefilled = True)
assert out["reasoning"] == "Let me compute 17*23 = 391"
assert out["visible"] == "The answer is 391."
assert out["monitor"] == "The answer is 391."
assert "<think>" not in out["reasoning"] and "</think>" not in out["visible"]
def test_s2_reasoning_flushed_before_tool_start():
# S2: reasoning streamed as reasoning_content, then flushed BEFORE tool_start.
events = [
{"type": "content", "text": "I should search"},
{"type": "content", "text": "I should search Sydney weather</think>"},
{"type": "tool_start", "tool_name": "web_search", "tool_call_id": "c0"},
{"type": "tool_end", "tool_name": "web_search", "tool_call_id": "c0"},
{"type": "status", "text": ""},
{"type": "content", "text": "Found it</think>Sydney is 21C today."},
]
out = _replay_sf_reasoning_stream(events, prefilled = True)
# Both turns' reasoning surfaced, answer only from turn 2.
assert "I should search Sydney weather" in out["reasoning"]
assert "Found it" in out["reasoning"]
assert out["visible"] == "Sydney is 21C today."
assert out["monitor"] == "Sydney is 21C today."
# Ordering: the pre-tool reasoning is emitted before the tool_start.
assert out["order"].index("reasoning") < out["order"].index("tool_start")
def test_s3_extractor_resets_each_turn():
# S3: multi-turn -> the two turns' reasoning are distinct (fresh extractor each).
events = [
{"type": "content", "text": "turn1 thoughts</think>partial"},
{"type": "status", "text": ""},
{"type": "content", "text": "turn2 thoughts</think>final answer"},
]
out = _replay_sf_reasoning_stream(events, prefilled = True)
assert out["reasoning"] == "turn1 thoughtsturn2 thoughts"
assert out["visible"] == "partialfinal answer"
def test_s4_harmony_full_tags_normal_mode():
# S4: gpt-oss / explicit-tag models use normal mode (prefilled=False).
events = [{"type": "content", "text": "<think>reasoning here</think>visible answer"}]
out = _replay_sf_reasoning_stream(events, prefilled = False)
assert out["reasoning"] == "reasoning here"
assert out["visible"] == "visible answer"
def test_s5_thinking_off_no_reasoning_deltas():
# S5: thinking disabled -> not prefilled, no </think>, all content is visible.
events = [{"type": "content", "text": "Just the plain answer, no thinking."}]
out = _replay_sf_reasoning_stream(events, prefilled = False)
assert out["reasoning"] == ""
assert out["visible"] == "Just the plain answer, no thinking."
assert out["monitor"] == "Just the plain answer, no thinking."
def test_s6_reasoning_effort_none_disables_prefill_for_enable_thinking_effort():
# GLM-5.2-style enable_thinking_effort: a request with reasoning_effort="none" (and
# enable_thinking omitted) disables thinking exactly like enable_thinking=False, so
# prefilled mode must be OFF. Otherwise the model emits no </think> and a plain
# answer is swallowed whole into reasoning_content, leaving the visible response
# empty (the exact bug: prefilled=True below eats the whole answer).
feats = {"reasoning_style": "enable_thinking_effort", "supports_reasoning": True}
assert _sf_reasoning_prefill_mode(feats, None, _THINK_TPL, "none") is False
# Thinking on (effort level or default) still prefills.
assert _sf_reasoning_prefill_mode(feats, None, _THINK_TPL, "high") is True
assert _sf_reasoning_prefill_mode(feats, None, _THINK_TPL, None) is True
# An explicit enable_thinking=False also disables (unchanged).
assert _sf_reasoning_prefill_mode(feats, False, _THINK_TPL, "high") is False
# reasoning_always_on wins regardless of reasoning_effort.
always = {**feats, "reasoning_always_on": True}
assert _sf_reasoning_prefill_mode(always, None, _THINK_TPL, "none") is True
# Plain enable_thinking models (Qwen) have no "none" sentinel; unaffected.
plain = {"reasoning_style": "enable_thinking", "supports_reasoning": True}
assert _sf_reasoning_prefill_mode(plain, None, _THINK_TPL, "none") is True
# End-to-end: with the corrected prefilled=False, a plain no-</think> answer is
# emitted as visible content rather than swallowed into the thinking drawer.
events = [{"type": "content", "text": "The capital of France is Paris."}]
out = _replay_sf_reasoning_stream(events, prefilled = False)
assert out["visible"] == "The capital of France is Paris."
assert out["reasoning"] == ""
# The buggy prefilled=True path is what swallowed the whole answer (guard the delta).
swallowed = _replay_sf_reasoning_stream(events, prefilled = True)
assert swallowed["visible"] == ""
assert swallowed["reasoning"] == "The capital of France is Paris."

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,179 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Deterministic backend-wiring test for the safetensors / MLX tool-calling path.
The parser and the cumulative-text state machine are already covered exhaustively by
``test_safetensors_tool_loop.py`` with fake generators. What that suite does not touch is the
*backend's own tool-injection seam*: both ``InferenceBackend`` (transformers) and
``MLXInferenceBackend`` render the prompt through the shared
``apply_chat_template_for_generation(..., tools=...)`` helper and stream cumulative text into the
shared ``run_safetensors_tool_loop`` (see ``core/inference/inference.py`` and
``core/inference/mlx_inference.py`` -- both call the same helper and the same loop, so a single CPU
test of that seam covers the macOS MLX path too).
This test drives that exact seam with deterministic fakes -- a fake tokenizer that records the
``tools`` it is handed, a canned tool-call generation, and a stub executor -- and asserts the full
agentic chain end to end:
tools injected into the template -> loop parses the call -> tool dispatched once ->
tool result fed back -> generation re-entered -> final answer streamed.
It is the deterministic, download-free stand-in for the real-model MLX / GGUF browser tool-calling
end-to-end: it imports no torch / unsloth / mlx, so it runs in the portable Backend CI alongside the
tool-call parser tests. Follow-up to the parser test PRs (#5620 / #5704).
"""
from core.inference.chat_template_helpers import apply_chat_template_for_generation
from core.inference.safetensors_agentic import run_safetensors_tool_loop
TOOL_NAME = "get_weather"
TOOL_ARGS = {"city": "Paris"}
FAKE_TOOL = {
"type": "function",
"function": {
"name": TOOL_NAME,
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}
# Full parser matrix lives in test_safetensors_tool_loop.py.
TOOL_CALL_TEXT = '<tool_call>{"name": "get_weather", "arguments": {"city": "Paris"}}</tool_call>'
FINAL_ANSWER = "The weather in Paris is sunny and 22C."
TOOL_RESULT = "Paris: sunny, 22C"
class RecordingTokenizer:
"""Fake tokenizer that records the ``tools`` handed to ``apply_chat_template``.
Modelled on ``TestChatTemplateHelper._Tok`` in ``test_safetensors_tool_loop.py``: it accepts the
real helper's kwargs and returns a canned prompt, so the test can assert the backend seam actually
forwarded the tool schema -- a silent drop on a chat-template fallback would leave ``tools_seen``
holding ``None``.
"""
def __init__(self):
self.tools_seen: list = []
self.call_count = 0
def apply_chat_template(
self,
messages,
*,
tokenize = False,
add_generation_prompt = True,
**kwargs,
):
self.call_count += 1
self.tools_seen.append(kwargs.get("tools"))
return "PROMPT"
class StubExecutor:
"""Stand-in for ``core.inference.tools.execute_tool``: records calls, returns a fixed result.
A fake tool name plus this stub means no real python / terminal / web / RAG side effect can run.
"""
def __init__(self, result: str):
self.result = result
self.calls: list[tuple[str, dict]] = []
def __call__(
self,
name,
arguments,
*,
cancel_event = None,
timeout = None,
session_id = None,
rag_scope = None,
disable_sandbox = False,
):
self.calls.append((name, arguments))
return self.result
def _collect(generator, max_events = 200):
events = []
for ev in generator:
events.append(ev)
if len(events) >= max_events:
break
return events
def _tool_names(tools):
return [(t.get("function") or {}).get("name") for t in (tools or [])]
def test_backend_seam_injects_tools_and_drives_full_tool_loop():
"""The shared backend seam forwards tools into the chat template, and the loop parses the call,
dispatches it once, feeds the result back, and re-enters generation for the final answer."""
tok = RecordingTokenizer()
executor = StubExecutor(TOOL_RESULT)
turns = iter([TOOL_CALL_TEXT, FINAL_ANSWER])
active_tools_seen: list = []
conversations_seen: list = []
def single_turn(conversation, *, active_tools = None):
# Mirror the real _single_turn: render via the shared helper, then yield cumulative snapshots.
active_tools_seen.append(active_tools)
conversations_seen.append([dict(m) for m in conversation])
apply_chat_template_for_generation(tok, conversation, tools = active_tools)
text = next(turns)
mid = len(text) // 2
acc = ""
for chunk in (text[:mid], text[mid:]):
acc += chunk
yield acc
events = _collect(
run_safetensors_tool_loop(
single_turn = single_turn,
messages = [{"role": "user", "content": "What is the weather in Paris?"}],
tools = [FAKE_TOOL],
execute_tool = executor,
max_tool_iterations = 3,
)
)
# 1. Helper forwarded the tool schema to the tokenizer (seam does not drop tools).
assert tok.tools_seen, "tokenizer.apply_chat_template was never called"
assert tok.tools_seen[0], "tool schema was dropped before reaching the tokenizer"
assert TOOL_NAME in _tool_names(tok.tools_seen[0])
# 2. Loop offered the tool to the first generation turn.
assert active_tools_seen and active_tools_seen[0] is not None
assert TOOL_NAME in _tool_names(active_tools_seen[0])
# 3 / 4 / 5. Exactly one tool_start, one dispatch with parsed args, one tool_end with the result.
tool_starts = [e for e in events if e["type"] == "tool_start"]
tool_ends = [e for e in events if e["type"] == "tool_end"]
assert len(tool_starts) == 1 and tool_starts[0]["tool_name"] == TOOL_NAME
assert executor.calls == [(TOOL_NAME, TOOL_ARGS)], executor.calls
assert len(tool_ends) == 1 and tool_ends[0]["result"] == TOOL_RESULT
# 6. Final answer streams after the tool result: loop appended it and re-entered generation.
contents = [e for e in events if e["type"] == "content"]
assert contents and FINAL_ANSWER in contents[-1]["text"]
last_tool_end_idx = max(i for i, e in enumerate(events) if e["type"] == "tool_end")
last_content_idx = max(i for i, e in enumerate(events) if e["type"] == "content")
assert last_content_idx > last_tool_end_idx, "final answer must stream after the tool result"
# 6b. Tool result fed back into the conversation before the final turn (6 alone misses this:
# the fake generation ignores the conversation).
assert len(conversations_seen) >= 2, "loop did not re-enter generation after the tool call"
final_turn_convo = conversations_seen[1]
assert any(
TOOL_RESULT in str(m.get("content", "")) for m in final_turn_convo
), "tool result was not fed back into the conversation before the final generation turn"
# 7. Guard: raw tool-call markup never leaked to the client as content.
for e in contents:
assert "<tool_call>" not in e["text"]
assert TOOL_NAME not in e["text"]

View file

@ -99,3 +99,16 @@ def test_malware_and_consent_gates_cover_the_lora_base():
if runs_gate and not resolves_base:
offenders.append(f"{rel} runs a load gate but never resolves the LoRA base")
assert not offenders, "\n".join(offenders)
def test_rag_embedding_path_runs_the_malware_gate():
"""The RAG embedding model is set through /settings and later loaded by
SentenceTransformer, which deserializes pickles; both sites must run the malware gate
or a flagged repo loads unscanned (bypassing the normal model-load protections)."""
offenders = []
for rel in ("routes/settings.py", "core/rag/embeddings.py"):
if "evaluate_file_security(" not in (_BACKEND / rel).read_text():
offenders.append(
f"{rel} loads/persists an embedding model without evaluate_file_security"
)
assert not offenders, "\n".join(offenders)

View file

@ -0,0 +1,786 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Client-tools passthrough healing for the safetensors/MLX backend.
Parity for #6801: when a NON-GGUF model is loaded and the request declares its
own ``tools`` with server-side tools OFF, text-form tool calls are promoted back
into structured ``tool_calls`` (declared tools only) via the shared healer. MLX
rides the same orchestrator path, so a single scripted backend covers both.
"""
import asyncio
import json
from types import SimpleNamespace
from models.inference import ChatCompletionRequest, ChatMessage
from routes.inference import openai_chat_completions
from core.inference.api_monitor import ApiMonitor
LOOKUP_TOOL = {
"type": "function",
"function": {
"name": "lookup",
"description": "Look something up",
"parameters": {
"type": "object",
"properties": {"q": {"type": "string"}},
"required": ["q"],
},
},
}
SEARCH_TOOL = {
"type": "function",
"function": {
"name": "search",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
}
_CALL_XML = '<tool_call>{"name": "lookup", "arguments": {"q": "cats"}}</tool_call>'
_SEARCH_XML = '<tool_call>{"name": "search", "arguments": {"query": "dogs"}}</tool_call>'
class _Request:
state = SimpleNamespace()
url = SimpleNamespace(path = "/v1/chat/completions")
method = "POST"
scope: dict = {}
async def is_disconnected(self):
return False
class _ScriptedBackend:
"""Non-GGUF backend: ``generate_chat_response`` replays scripted
CUMULATIVE snapshots. ``responder(messages, tools)`` returns the snapshot
list for one generation, so nudge tests can vary output across turns."""
active_model_name = "sf-model"
def __init__(
self,
responder,
*,
stats = None,
):
self.models = {
"sf-model": {
"chat_template_info": {"template": "<tool_call> chatml"},
"context_length": 2048,
}
}
self._responder = responder
self._stats = stats
self.calls: list = []
self.reset_count = 0
def generate_chat_response(
self,
*,
messages,
tools = None,
stats_holder = None,
**kwargs,
):
self.calls.append({"messages": messages, "tools": tools, **kwargs})
snapshots = self._responder(messages, tools)
if stats_holder is not None and self._stats is not None:
stats_holder["stats"] = self._stats
for snap in snapshots:
yield snap
def reset_generation_state(self):
self.reset_count += 1
def _fixed(*snapshots):
"""Responder that always replays the given cumulative snapshots."""
return lambda messages, tools: list(snapshots)
def _llama_stub():
return SimpleNamespace(
is_loaded = False,
supports_tools = False,
is_vision = False,
context_length = None,
)
def _install(
monkeypatch,
backend,
*,
supports_tools = True,
):
import routes.inference as inf
from state.tool_policy import reset_tool_policy
reset_tool_policy()
monitor = ApiMonitor(max_entries = 8)
monkeypatch.setattr(inf, "api_monitor", monitor)
monkeypatch.setattr(inf, "get_llama_cpp_backend", lambda: _llama_stub())
monkeypatch.setattr(inf, "get_inference_backend", lambda: backend)
monkeypatch.setattr(
inf,
"_detect_safetensors_features",
lambda *a, **k: {"supports_tools": supports_tools},
)
return monitor
def _request(**kwargs):
base = dict(model = "default", messages = [ChatMessage(role = "user", content = "hi")])
base.update(kwargs)
return ChatCompletionRequest(**base)
def _call(payload, monkeypatch, backend, **install_kwargs):
_install(monkeypatch, backend, **install_kwargs)
async def _run():
return await openai_chat_completions(payload, request = _Request(), current_subject = "u")
return asyncio.run(_run())
def _json_body(response):
return json.loads(response.body if hasattr(response, "body") else response.content)
def _collect_sse(response):
async def _run():
return [c async for c in response.body_iterator]
return asyncio.run(_run())
def _sse_objects(chunks):
out = []
for chunk in chunks:
if isinstance(chunk, bytes):
chunk = chunk.decode()
for line in str(chunk).splitlines():
if line.startswith("data: "):
data = line.removeprefix("data: ")
if data != "[DONE]":
out.append(json.loads(data))
return out
# ── Non-streaming ─────────────────────────────────────────────────
def test_xml_healed_to_tool_calls_non_streaming(monkeypatch):
backend = _ScriptedBackend(_fixed(_CALL_XML))
payload = _request(tools = [LOOKUP_TOOL], stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
choice = body["choices"][0]
assert choice["finish_reason"] == "tool_calls"
assert choice["message"]["content"] is None
calls = choice["message"]["tool_calls"]
assert len(calls) == 1
assert calls[0]["function"]["name"] == "lookup"
assert json.loads(calls[0]["function"]["arguments"]) == {"q": "cats"}
# The client tools reached the generator (template injection).
assert backend.calls[0]["tools"] == [LOOKUP_TOOL]
def test_undeclared_call_stays_text(monkeypatch):
xml = '<tool_call>{"name": "other", "arguments": {}}</tool_call>'
backend = _ScriptedBackend(_fixed(xml))
payload = _request(tools = [LOOKUP_TOOL], stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
choice = body["choices"][0]
assert choice["finish_reason"] == "stop"
assert choice["message"].get("tool_calls") is None
assert choice["message"]["content"] == xml
def test_opt_out_relays_verbatim(monkeypatch):
backend = _ScriptedBackend(_fixed(_CALL_XML))
payload = _request(tools = [LOOKUP_TOOL], stream = False, auto_heal_tool_calls = False)
body = _json_body(_call(payload, monkeypatch, backend))
choice = body["choices"][0]
assert choice["finish_reason"] == "stop"
assert choice["message"].get("tool_calls") is None
assert choice["message"]["content"] == _CALL_XML
def test_env_kill_switch_relays_verbatim(monkeypatch):
import core.inference.passthrough_healing as ph
monkeypatch.setattr(ph, "_HEALING_DISABLED", True)
backend = _ScriptedBackend(_fixed(_CALL_XML))
payload = _request(tools = [LOOKUP_TOOL], stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
choice = body["choices"][0]
assert choice["finish_reason"] == "stop"
assert choice["message"].get("tool_calls") is None
assert choice["message"]["content"] == _CALL_XML
def test_no_tools_request_untouched(monkeypatch):
backend = _ScriptedBackend(_fixed("just a plain answer"))
payload = _request(stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
# No tools and no tool messages -> plain path, normal ChatCompletion.
choice = body["choices"][0]
assert choice["finish_reason"] == "stop"
assert choice["message"]["content"] == "just a plain answer"
assert choice["message"].get("tool_calls") is None
def test_prose_around_call_retained(monkeypatch):
text = "Let me look:\n" + _CALL_XML + "\ndone"
backend = _ScriptedBackend(_fixed(text))
payload = _request(tools = [LOOKUP_TOOL], stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
choice = body["choices"][0]
assert choice["finish_reason"] == "tool_calls"
assert choice["message"]["content"] == "Let me look:\n\ndone"
assert choice["message"]["tool_calls"][0]["function"]["name"] == "lookup"
def test_empty_output_is_valid_stop(monkeypatch):
backend = _ScriptedBackend(_fixed(""))
payload = _request(tools = [LOOKUP_TOOL], stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
choice = body["choices"][0]
assert choice["finish_reason"] == "stop"
assert choice["message"]["content"] in ("", None)
assert choice["message"].get("tool_calls") is None
def test_tool_role_follow_up_turn_preserves_history(monkeypatch):
backend = _ScriptedBackend(_fixed("The weather is sunny."))
payload = _request(
tools = [LOOKUP_TOOL],
stream = False,
messages = [
ChatMessage(role = "user", content = "weather?"),
ChatMessage(
role = "assistant",
content = None,
tool_calls = [
{
"id": "call_0",
"type": "function",
"function": {"name": "lookup", "arguments": '{"q": "weather"}'},
}
],
),
ChatMessage(role = "tool", tool_call_id = "call_0", content = "sunny"),
],
)
body = _json_body(_call(payload, monkeypatch, backend))
assert body["choices"][0]["message"]["content"] == "The weather is sunny."
# The tool history reached the generator intact (role=tool + assistant.tool_calls).
sent = backend.calls[0]["messages"]
roles = [m["role"] for m in sent]
assert "tool" in roles
assistant = next(m for m in sent if m["role"] == "assistant")
assert assistant.get("tool_calls")
def test_dict_arguments_history_does_not_crash(monkeypatch):
# Non-spec client: assistant tool_calls[].function.arguments as a dict.
backend = _ScriptedBackend(_fixed("ok"))
payload = _request(
tools = [LOOKUP_TOOL],
stream = False,
messages = [
ChatMessage(role = "user", content = "hi"),
ChatMessage(
role = "assistant",
content = None,
tool_calls = [
{
"id": "call_0",
"type": "function",
"function": {"name": "lookup", "arguments": {"q": "x"}},
}
],
),
ChatMessage(role = "tool", tool_call_id = "call_0", content = "y"),
],
)
body = _json_body(_call(payload, monkeypatch, backend))
assert body["choices"][0]["message"]["content"] == "ok"
def test_forced_tool_choice_narrows_promotion(monkeypatch):
# tool_choice forces `search`; a `lookup` text call must NOT promote.
backend = _ScriptedBackend(_fixed(_CALL_XML))
payload = _request(
tools = [LOOKUP_TOOL, SEARCH_TOOL],
stream = False,
tool_choice = {"type": "function", "function": {"name": "search"}},
)
body = _json_body(_call(payload, monkeypatch, backend))
choice = body["choices"][0]
assert choice["finish_reason"] == "stop"
assert choice["message"].get("tool_calls") is None
def test_parallel_cap_non_streaming(monkeypatch):
backend = _ScriptedBackend(_fixed(_CALL_XML + _SEARCH_XML))
payload = _request(tools = [LOOKUP_TOOL, SEARCH_TOOL], stream = False, parallel_tool_calls = False)
body = _json_body(_call(payload, monkeypatch, backend))
calls = body["choices"][0]["message"]["tool_calls"]
assert len(calls) == 1
assert calls[0]["function"]["name"] == "lookup"
def test_usage_recorded_when_stats_present(monkeypatch):
stats = {"usage": {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10}}
backend = _ScriptedBackend(_fixed(_CALL_XML), stats = stats)
payload = _request(tools = [LOOKUP_TOOL], stream = False)
monitor = _install(monkeypatch, backend)
async def _run():
return await openai_chat_completions(payload, request = _Request(), current_subject = "u")
asyncio.run(_run())
[entry] = monitor.snapshot()
assert entry["prompt_tokens"] == 7
assert entry["completion_tokens"] == 3
# ── Nudge ─────────────────────────────────────────────────────────
def test_nudge_default_off_single_generation(monkeypatch):
# Signal present but unparseable; without opt-in, no retry.
truncated = '<tool_call>{"name": "lookup"'
backend = _ScriptedBackend(_fixed(truncated))
payload = _request(tools = [LOOKUP_TOOL], stream = False)
_call(payload, monkeypatch, backend)
assert len(backend.calls) == 1
def test_nudge_opt_in_retry_recovers(monkeypatch):
truncated = '<tool_call>{"name": "lookup"'
def responder(messages, tools):
nudged = any(
"native tool-call format" in (m.get("content") or "")
for m in messages
if m.get("role") == "user"
)
return [_CALL_XML] if nudged else [truncated]
backend = _ScriptedBackend(responder)
payload = _request(tools = [LOOKUP_TOOL], stream = False, nudge_tool_calls = True)
body = _json_body(_call(payload, monkeypatch, backend))
assert len(backend.calls) == 2
choice = body["choices"][0]
assert choice["finish_reason"] == "tool_calls"
assert choice["message"]["tool_calls"][0]["function"]["name"] == "lookup"
def test_nudge_double_failure_relays_original(monkeypatch):
truncated = '<tool_call>{"name": "lookup"'
backend = _ScriptedBackend(_fixed(truncated))
payload = _request(tools = [LOOKUP_TOOL], stream = False, nudge_tool_calls = True)
body = _json_body(_call(payload, monkeypatch, backend))
assert len(backend.calls) == 2 # exactly one retry
choice = body["choices"][0]
assert choice["finish_reason"] == "stop"
assert choice["message"]["content"] == truncated
# ── Streaming ─────────────────────────────────────────────────────
def test_streaming_heals_split_call_into_one_delta(monkeypatch):
# Cumulative snapshots that build the call across many increments.
pieces = ["<tool", '<tool_call>{"name": "loo', '<tool_call>{"name": "lookup", "argum']
cumulative = pieces + [_CALL_XML]
backend = _ScriptedBackend(_fixed(*cumulative))
payload = _request(tools = [LOOKUP_TOOL], stream = True)
response = _call(payload, monkeypatch, backend)
objs = _sse_objects(_collect_sse(response))
tool_deltas = [
tc
for o in objs
for tc in (o.get("choices", [{}])[0].get("delta", {}) or {}).get("tool_calls", []) or []
]
assert len(tool_deltas) == 1
assert tool_deltas[0]["function"]["name"] == "lookup"
finishes = [
o["choices"][0]["finish_reason"]
for o in objs
if o["choices"] and o["choices"][0].get("finish_reason")
]
assert finishes == ["tool_calls"]
def test_streaming_cancel_does_not_finalize_tool_call(monkeypatch):
# A stream cancelled via the registry ("Stop") must NOT promote the
# buffered-but-unclosed tool markup at finalize, else it executes a tool
# the user just cancelled. Guarded on cancel_event at the finalize step.
import routes.inference as inf
cancel_id = "cancel-me-6870"
# Balanced JSON but no closing </tool_call> -> healer HOLDS it until finalize.
held = '<tool_call>{"name": "lookup", "arguments": {"q": "cats"}}'
class _CancelMidStream(_ScriptedBackend):
def __init__(self):
super().__init__(_fixed(held))
def generate_chat_response(
self,
*,
messages,
tools = None,
stats_holder = None,
**kwargs,
):
self.calls.append({"messages": messages, "tools": tools, **kwargs})
yield held # healer holds the unclosed call
inf._cancel_by_cancel_id_or_stash(cancel_id) # user hits Stop before EOF
backend = _CancelMidStream()
payload = _request(tools = [LOOKUP_TOOL], stream = True, cancel_id = cancel_id)
response = _call(payload, monkeypatch, backend)
objs = _sse_objects(_collect_sse(response))
tool_deltas = [
tc
for o in objs
for tc in (o.get("choices", [{}])[0].get("delta", {}) or {}).get("tool_calls", []) or []
]
assert tool_deltas == [] # no tool promoted after cancel
finishes = [
o["choices"][0]["finish_reason"]
for o in objs
if o["choices"] and o["choices"][0].get("finish_reason")
]
assert "tool_calls" not in finishes # ends with finish_reason=stop, not tool_calls
def test_streaming_no_tools_verbatim(monkeypatch):
backend = _ScriptedBackend(_fixed("hello ", "hello world"))
payload = _request(stream = True)
response = _call(payload, monkeypatch, backend)
objs = _sse_objects(_collect_sse(response))
text = "".join(
(o["choices"][0]["delta"].get("content") or "")
for o in objs
if o["choices"] and "delta" in o["choices"][0]
)
assert text == "hello world"
finishes = [
o["choices"][0]["finish_reason"]
for o in objs
if o["choices"] and o["choices"][0].get("finish_reason")
]
assert finishes == ["stop"]
def test_streaming_repeated_snapshot_no_duplicate_call(monkeypatch):
# Repeated then shrunk cumulative snapshots must not double-heal.
backend = _ScriptedBackend(_fixed(_CALL_XML, _CALL_XML, _CALL_XML[:5], _CALL_XML))
payload = _request(tools = [LOOKUP_TOOL], stream = True)
response = _call(payload, monkeypatch, backend)
objs = _sse_objects(_collect_sse(response))
tool_deltas = [
tc
for o in objs
for tc in (o.get("choices", [{}])[0].get("delta", {}) or {}).get("tool_calls", []) or []
]
assert len(tool_deltas) == 1
def test_streaming_parallel_cap(monkeypatch):
backend = _ScriptedBackend(_fixed(_CALL_XML + _SEARCH_XML))
payload = _request(tools = [LOOKUP_TOOL, SEARCH_TOOL], stream = True, parallel_tool_calls = False)
response = _call(payload, monkeypatch, backend)
objs = _sse_objects(_collect_sse(response))
tool_deltas = [
tc
for o in objs
for tc in (o.get("choices", [{}])[0].get("delta", {}) or {}).get("tool_calls", []) or []
]
assert len(tool_deltas) == 1
assert tool_deltas[0]["function"]["name"] == "lookup"
def test_streaming_generator_error_closes_cleanly(monkeypatch):
def responder(messages, tools):
raise RuntimeError("boom /secret/path")
backend = _ScriptedBackend(responder)
payload = _request(tools = [LOOKUP_TOOL], stream = True)
response = _call(payload, monkeypatch, backend)
chunks = _collect_sse(response)
joined = "".join(c.decode() if isinstance(c, bytes) else c for c in chunks)
assert "An internal error occurred" in joined
assert "secret/path" not in joined # CWE-209: no path leak
assert backend.reset_count >= 1
def test_streaming_disconnect_resets_once(monkeypatch):
class _DisconnectRequest(_Request):
async def is_disconnected(self):
return True
backend = _ScriptedBackend(_fixed("a", "ab", "abc"))
payload = _request(tools = [LOOKUP_TOOL], stream = True)
_install(monkeypatch, backend)
async def _run():
resp = await openai_chat_completions(
payload, request = _DisconnectRequest(), current_subject = "u"
)
return [c async for c in resp.body_iterator]
asyncio.run(_run())
assert backend.reset_count == 1
def test_mlx_uses_same_path(monkeypatch):
# MLX and safetensors share get_inference_backend(); one scripted backend covers both.
backend = _ScriptedBackend(_fixed(_CALL_XML))
payload = _request(tools = [LOOKUP_TOOL], stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
assert body["choices"][0]["finish_reason"] == "tool_calls"
def test_tool_choice_none_does_not_advertise_tools(monkeypatch):
# tool_choice="none": no tools rendered into the template; history templating still applies.
backend = _ScriptedBackend(_fixed("plain answer"))
payload = _request(tools = [LOOKUP_TOOL], tool_choice = "none", stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
assert body["choices"][0]["message"]["content"] == "plain answer"
assert backend.calls[0]["tools"] is None
def test_developer_message_folded_into_system_prompt(monkeypatch):
# The "developer" role folds into one leading system message (local templates reject it).
backend = _ScriptedBackend(_fixed("ok"))
payload = _request(
messages = [
ChatMessage(role = "developer", content = "always be terse"),
ChatMessage(role = "user", content = "hi"),
],
tools = [LOOKUP_TOOL],
stream = False,
)
_call(payload, monkeypatch, backend)
sent = backend.calls[0]["messages"]
assert sent[0]["role"] == "system"
assert "always be terse" in sent[0]["content"]
assert all(m.get("role") != "developer" for m in sent)
def test_failed_nudge_retry_keeps_original_response(monkeypatch):
# A raising retry must not 500; the first response is returned.
state = {"n": 0}
def responder(messages, tools):
state["n"] += 1
if state["n"] == 1:
return ['<tool_call>{"name":"lookup"'] # unhealable signal
raise RuntimeError("retry blew up")
backend = _ScriptedBackend(responder)
payload = _request(tools = [LOOKUP_TOOL], nudge_tool_calls = True, stream = False)
body = _json_body(_call(payload, monkeypatch, backend))
assert state["n"] == 2
assert body["choices"][0]["finish_reason"] == "stop"
assert body["choices"][0]["message"]["content"] == '<tool_call>{"name":"lookup"'
def test_discarded_nudge_retry_reports_first_attempt_usage(monkeypatch):
# Double-failure nudge: the first response is delivered, but the retry's
# generate() overwrites stats_holder. The monitor must record the FIRST
# attempt's usage, not the discarded retry's.
first_stats = {"usage": {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10}}
retry_stats = {"usage": {"prompt_tokens": 99, "completion_tokens": 99, "total_tokens": 198}}
class _PerCallStatsBackend(_ScriptedBackend):
def __init__(self):
# Unhealable truncated markup on both attempts -> retry is discarded.
super().__init__(lambda m, t: ['<tool_call>{"name":"lookup"'])
self._stats_seq = [first_stats, retry_stats]
def generate_chat_response(
self,
*,
messages,
tools = None,
stats_holder = None,
**kwargs,
):
self.calls.append({"messages": messages, "tools": tools, **kwargs})
stats = self._stats_seq[min(len(self.calls) - 1, len(self._stats_seq) - 1)]
if stats_holder is not None:
stats_holder["stats"] = stats
for snap in self._responder(messages, tools):
yield snap
backend = _PerCallStatsBackend()
payload = _request(tools = [LOOKUP_TOOL], nudge_tool_calls = True, stream = False)
monitor = _install(monkeypatch, backend)
async def _run():
return await openai_chat_completions(payload, request = _Request(), current_subject = "u")
asyncio.run(_run())
assert len(backend.calls) == 2 # first attempt + one discarded retry
[entry] = monitor.snapshot()
# The delivered response is the first attempt, so its usage must be reported.
assert entry["prompt_tokens"] == 7
assert entry["completion_tokens"] == 3
def test_monitor_records_healed_call_not_raw_xml(monkeypatch):
backend = _ScriptedBackend(_fixed(_CALL_XML))
payload = _request(tools = [LOOKUP_TOOL], stream = False)
monitor = _install(monkeypatch, backend)
async def _run():
return await openai_chat_completions(payload, request = _Request(), current_subject = "u")
asyncio.run(_run())
snap = monitor.snapshot(include_details = True)
replies = json.dumps(snap)
assert "<tool_call>" not in replies
assert "lookup" in replies
def test_streaming_monitor_records_healed_call_not_raw_xml(monkeypatch):
# Monitor mirrors what the client received, never the healed-away raw markup.
backend = _ScriptedBackend(
_fixed("Sure. ", 'Sure. <tool_call>{"name": "loo', "Sure. " + _CALL_XML)
)
payload = _request(tools = [LOOKUP_TOOL], stream = True)
monitor = _install(monkeypatch, backend)
async def _run():
return await openai_chat_completions(payload, request = _Request(), current_subject = "u")
response = asyncio.run(_run())
_collect_sse(response)
replies = json.dumps(monitor.snapshot(include_details = True))
assert "<tool_call>" not in replies
assert "Sure. " in replies
assert "[tool_calls] lookup(" in replies
def test_forced_tool_choice_narrows_templated_tools(monkeypatch):
# A forced function is the only schema rendered into the template.
backend = _ScriptedBackend(_fixed(_SEARCH_XML))
payload = _request(
tools = [LOOKUP_TOOL, SEARCH_TOOL],
stream = False,
tool_choice = {"type": "function", "function": {"name": "search"}},
)
body = _json_body(_call(payload, monkeypatch, backend))
templated = backend.calls[0]["tools"]
assert [t["function"]["name"] for t in templated] == ["search"]
choice = body["choices"][0]
assert choice["finish_reason"] == "tool_calls"
assert choice["message"]["tool_calls"][0]["function"]["name"] == "search"
def test_multimodal_content_parts_flattened_for_local_template(monkeypatch):
# Remote image URLs leave image=None, so content arrives as a part LIST:
# text parts are kept, the image part dropped.
backend = _ScriptedBackend(_fixed(_CALL_XML))
payload = _request(
messages = [
ChatMessage(
role = "user",
content = [
{"type": "text", "text": "what is this?"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/cat.png"},
},
],
)
],
tools = [LOOKUP_TOOL],
stream = False,
)
body = _json_body(_call(payload, monkeypatch, backend))
templated = backend.calls[0]["messages"]
assert all(isinstance(m.get("content"), str) for m in templated)
assert any(m["content"] == "what is this?" for m in templated)
assert body["choices"][0]["finish_reason"] == "tool_calls"
def test_string_arguments_history_deserialized_for_template(monkeypatch):
# JSON-string tool_calls arguments become dicts in the templated copy;
# the HTTP response stays OpenAI-shaped.
backend = _ScriptedBackend(_fixed("done"))
payload = _request(
tools = [LOOKUP_TOOL],
stream = False,
messages = [
ChatMessage(role = "user", content = "weather?"),
ChatMessage(
role = "assistant",
content = None,
tool_calls = [
{
"id": "call_0",
"type": "function",
"function": {"name": "lookup", "arguments": '{"q": "weather"}'},
}
],
),
ChatMessage(role = "tool", tool_call_id = "call_0", content = "sunny"),
],
)
_json_body(_call(payload, monkeypatch, backend))
assistant = next(m for m in backend.calls[0]["messages"] if m["role"] == "assistant")
assert assistant["tool_calls"][0]["function"]["arguments"] == {"q": "weather"}
def test_unparseable_arguments_string_left_untouched(monkeypatch):
backend = _ScriptedBackend(_fixed("ok"))
payload = _request(
tools = [LOOKUP_TOOL],
stream = False,
messages = [
ChatMessage(role = "user", content = "hi"),
ChatMessage(
role = "assistant",
content = None,
tool_calls = [
{
"id": "call_0",
"type": "function",
"function": {"name": "lookup", "arguments": "not json {"},
}
],
),
ChatMessage(role = "tool", tool_call_id = "call_0", content = "y"),
],
)
body = _json_body(_call(payload, monkeypatch, backend))
assert body["choices"][0]["message"]["content"] == "ok"
assistant = next(m for m in backend.calls[0]["messages"] if m["role"] == "assistant")
assert assistant["tool_calls"][0]["function"]["arguments"] == "not json {"
def test_mcp_enabled_without_server_tools_uses_passthrough(monkeypatch):
# mcp_enabled=true with an empty registry must not silently drop the
# declared tools; the gate keys on the server-side path claiming the request.
backend = _ScriptedBackend(_fixed(_CALL_XML))
payload = _request(tools = [LOOKUP_TOOL], stream = False, mcp_enabled = True)
body = _json_body(_call(payload, monkeypatch, backend))
choice = body["choices"][0]
assert choice["finish_reason"] == "tool_calls"
assert choice["message"]["tool_calls"][0]["function"]["name"] == "lookup"
assert backend.calls[0]["tools"] == [LOOKUP_TOOL]

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