Merge remote-tracking branch 'origin/main' into r5748

This commit is contained in:
Daniel Han 2026-07-16 04:54:39 +00:00
commit 1c39a283f6
426 changed files with 66427 additions and 6621 deletions

View file

@ -376,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
@ -449,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
@ -527,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

@ -272,6 +272,7 @@ jobs:
tests/saving/test_export_api_surface.py \
tests/saving/test_export_dispatch.py \
tests/saving/test_imatrix_export.py \
tests/saving/test_gguf_single_pass_export.py \
tests/utils/test_attention_masks.py \
tests/utils/test_trunc_normal_patch.py \
tests/python/test_fast_language_model_text_only.py
@ -361,10 +362,13 @@ jobs:
tests/saving/test_export_api_surface.py \
tests/saving/test_export_dispatch.py \
tests/saving/test_imatrix_export.py \
tests/saving/test_gguf_single_pass_export.py \
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

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Runs tests/python/test_cross_platform_parity.py on Windows and macOS.
# Runs installer parity and autostart opt-out tests on Windows and macOS.
#
# Why: that test is the guard that install.sh and install.ps1 stay in
# sync, but today it only runs on ubuntu-latest (auto-discovered by
@ -21,6 +21,7 @@ on:
paths:
- 'install.sh'
- 'install.ps1'
- 'tests/test_installer_skip_autostart.py'
- 'tests/python/test_cross_platform_parity.py'
- '.github/workflows/cross-platform-parity-ci.yml'
push:
@ -28,6 +29,7 @@ on:
paths:
- 'install.sh'
- 'install.ps1'
- 'tests/test_installer_skip_autostart.py'
- 'tests/python/test_cross_platform_parity.py'
- '.github/workflows/cross-platform-parity-ci.yml'
workflow_dispatch:
@ -57,5 +59,11 @@ jobs:
python-version: '3.12'
cache: 'pip'
- run: python -m pip install -U pip pytest
- name: Cross-platform parity test
run: python -m pytest tests/python/test_cross_platform_parity.py -q
- name: Cross-platform parity tests
env:
UNSLOTH_NO_TORCH: '1'
run: >-
python -m pytest
tests/python/test_cross_platform_parity.py
tests/test_installer_skip_autostart.py
-q

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

@ -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,10 +466,26 @@ 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):
def post_sse(path, body, *, timeout = 600, retries = 1, complete_on = None):
"""POST a streaming request and accumulate the assistant
text deltas. The server-side agentic loop ALWAYS returns
SSE regardless of the request's `stream` field, so any
@ -483,6 +501,22 @@ jobs:
invocation markers / tool output, since
`delta.content` alone is not evidence
that the tool path executed.
A shared CI runner can stall the stream transport (the
connection opening, or a mid-stream read) even when Studio
is healthy, so retry a stall once with a fresh request
capped at 300s. A stall means the stream did NOT complete,
so partial events are normally NOT returned (an early
tool_start with no tool_end is not proof the tool loop
finished). The one exception is `complete_on`: an optional
predicate over the events collected so far -- when a stall
happens after it is already satisfied (the tool ran and
produced its result before the trailing read timed out),
those events are returned rather than discarded, so the
stall-after-answer case still counts. HTTP status errors
surface immediately; a stall that yields no completed result
across all attempts re-raises so the caller can rotate to
the next seed.
"""
body = {**body, "stream": True}
data = json.dumps(body).encode()
@ -495,26 +529,45 @@ jobs:
"Content-Type": "application/json",
},
)
parts = []
events = []
with urllib.request.urlopen(req, timeout = timeout) as resp:
for raw in resp:
line = raw.decode().strip()
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
break
events.append(payload)
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
for choice in chunk.get("choices", []):
delta = choice.get("delta", {}) or {}
if delta.get("content"):
parts.append(delta["content"])
return "".join(parts), events
for attempt in range(retries + 1):
parts = []
events = []
t = timeout if attempt == 0 else min(timeout, 300)
try:
with urllib.request.urlopen(req, timeout = t) as resp:
for raw in resp:
line = raw.decode().strip()
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
break
events.append(payload)
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
for choice in chunk.get("choices", []):
delta = choice.get("delta", {}) or {}
if delta.get("content"):
parts.append(delta["content"])
return "".join(parts), events
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
# A stall after the tool already produced its result is
# the case this probe exists to tolerate: keep those
# events. But a stall with only an early tool_start (no
# completed output) is not proof the tool loop finished,
# so it must not pass -- retry once, then raise so
# _run_tool_probe rotates to the next seed.
if complete_on is not None and complete_on(events):
print(f"[retry-sse] {path}: {exc!r}; keeping {len(events)} completed events", flush = True)
return "".join(parts), events
if attempt == retries:
raise
print(f"[retry-sse] {path}: {exc!r}", flush = True)
time.sleep(15)
_STUDIO_TOOL_TYPES = {
"tool_start", "tool_end", "tool_use", "tool_result",
@ -651,17 +704,54 @@ jobs:
"""
attempts_log = []
best = None
# Cap the wall-clock spent rotating through stalled seeds so a
# persistent no-data wedge fails fast (clean assertion) instead
# of being killed by the job's timeout-minutes. A healthy or
# merely degenerate round answers in seconds, so all seeds still
# run in the normal case; only stalls consume the budget.
probe_deadline = time.monotonic() + 300
for attempt_i in range(max_attempts):
# Cap each read by the budget still remaining (not just a flat
# 180s) and skip an attempt too small to finish, so the whole
# rotation stays within ~300s -- two probes then fit the job's
# timeout-minutes even if every seed stalls.
remaining = int(probe_deadline - time.monotonic())
if attempt_i and remaining < 30:
print(f"[tools] {label}: seed-rotation budget spent after {attempt_i} attempts", flush = True)
break
attempt_seed = SEED + attempt_i
content, events = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": prompt}],
"enable_tools": True,
"enabled_tools": enabled,
"session_id": f"{session}-att{attempt_i}",
"temperature": TOOL_PROBE_TEMP,
"seed": attempt_seed,
"max_tokens": 600,
})
try:
# Bounded per-attempt timeout, no inner retry -- the seed
# loop IS the retry, so a stall raises quickly and rotates
# rather than spending post_sse's full 600+300s. complete_on
# keeps a stall that already produced the tool result (only
# the trailing read timed out) instead of discarding it.
content, events = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": prompt}],
"enable_tools": True,
"enabled_tools": enabled,
"session_id": f"{session}-att{attempt_i}",
"temperature": TOOL_PROBE_TEMP,
"seed": attempt_seed,
"max_tokens": 600,
}, timeout = min(180, remaining), retries = 0,
complete_on = lambda ev: _tool_invoked(ev) and _tool_output_contains(ev, *needles))
except urllib.error.HTTPError:
# HTTPError subclasses URLError, so re-raise a real 4xx/5xx
# here instead of letting the transport-stall handler below
# swallow it and rotate seeds -- an endpoint status failure
# must surface, not be masked as missing tool evidence.
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
# A transport stall that outlived post_sse's own retry:
# log it as a failed attempt and rotate to the next seed
# rather than sinking the whole probe on one bad stream.
attempts_log.append({
"attempt": attempt_i, "seed": attempt_seed,
"transport_error": repr(exc),
})
print(f"[tools] retry {label} attempt {attempt_i}: transport {exc!r}", flush = True)
continue
invoked = _tool_invoked(events)
produced = _tool_output_contains(events, *needles)
attempts_log.append({
@ -722,6 +812,9 @@ jobs:
# enough that requiring a tool_call marker would create
# red-herring failures from infra rather than from Studio.
try:
# Best-effort and bounded: a single 180s attempt keeps a stall
# from eating the job's timeout-minutes (it already WARNs, so a
# retry buys nothing).
content, events = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
"enable_tools": True,
@ -730,7 +823,7 @@ jobs:
"temperature": 0.0,
"seed": SEED,
"max_tokens": 400,
})
}, timeout = 180, retries = 0)
print(
f"[tools] PASS web_search stream ({len(content)} chars in content, "
f"{len(events)} raw events)"
@ -938,6 +1031,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 +1051,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,14 +452,41 @@ 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):
def post_sse(path, body, *, timeout = 600, retries = 1, soft = False):
"""POST a streaming request and accumulate the assistant
text deltas. The server-side agentic loop ALWAYS returns
SSE regardless of the request's `stream` field, so any
call with enable_tools=true must use this helper."""
call with enable_tools=true must use this helper.
A shared CI runner can stall the stream transport (the
connection opening, or a mid-stream read) even when Studio
is healthy, so harden the read three ways: retry a stall
once with a fresh request capped at 300s; return any text
already streamed before a stall (a stall on the trailing
tokens, after the answer arrived, still counts); and when
every attempt yields nothing, a hard call re-raises while a
soft call (the best-effort server-side tool probes) returns
None so the caller can WARN instead of sinking the whole
job. HTTP status errors always surface immediately."""
body = {**body, "stream": True}
data = json.dumps(body).encode()
req = urllib.request.Request(
@ -469,24 +498,43 @@ jobs:
"Content-Type": "application/json",
},
)
parts = []
with urllib.request.urlopen(req, timeout = timeout) as resp:
for raw in resp:
line = raw.decode().strip()
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
break
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
for choice in chunk.get("choices", []):
delta = choice.get("delta", {}) or {}
if delta.get("content"):
parts.append(delta["content"])
return "".join(parts)
for attempt in range(retries + 1):
parts = []
t = timeout if attempt == 0 else min(timeout, 300)
try:
with urllib.request.urlopen(req, timeout = t) as resp:
for raw in resp:
line = raw.decode().strip()
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
break
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
for choice in chunk.get("choices", []):
delta = choice.get("delta", {}) or {}
if delta.get("content"):
parts.append(delta["content"])
return "".join(parts)
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
# Text already streamed is a valid signal -- keep it
# rather than re-running a heavy generation.
if parts:
joined = "".join(parts)
print(f"[retry-sse] {path}: {exc!r}; keeping {len(joined)} partial chars", flush = True)
return joined
if attempt == retries:
if soft:
print(f"[tools] WARN {path}: SSE transport stalled with no data ({exc!r}) -- non-blocking", flush = True)
return None
raise
print(f"[retry-sse] {path}: {exc!r}", flush = True)
time.sleep(15)
# ── 1. Standard OpenAI function calling ──────────────────────
weather_tool = {
@ -557,6 +605,10 @@ jobs:
# macos-14 free runner is ~10 tok/s on Qwen3.5-2B Q4_K_XL;
# cap max_tokens tightly so each SSE round stays under ~30s
# even when the model stalls in a degenerate output state.
# retries=0 on the best-effort probes: this job's 25-minute cap
# allows a 10-minute model load, so a no-data stall must be a
# single 180s attempt (not 180+15+180s) to leave room for the
# thinking checks. A soft/best-effort probe only WARNs anyway.
content = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}],
"enable_tools": True,
@ -565,8 +617,10 @@ jobs:
"temperature": TEMP,
"seed": SEED,
"max_tokens": 128,
}, timeout = 180)
if "56088" in content or "56,088" in content:
}, timeout = 180, retries = 0, soft = True)
if content is None:
print("[tools] WARN python tool: SSE transport stalled after retries -- non-blocking")
elif "56088" in content or "56,088" in content:
print(f"[tools] PASS python tool ({len(content)} chars, found 56088)")
else:
# Empty stream is a known Mac-quant degeneracy too; log
@ -598,7 +652,7 @@ jobs:
"temperature": TEMP,
"seed": SEED,
"max_tokens": 96,
}, timeout = 180)
}, timeout = 180, retries = 0)
print(f"[tools] PASS web_search stream ({len(content)} chars)")
except Exception as exc:
print(f"[tools] WARN web_search probe failed (non-blocking): {exc}")
@ -825,6 +879,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 +904,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,10 +658,41 @@ 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):
def post_sse(path, body, *, timeout = 600, retries = 1, soft = False):
# The server-side agentic loop always answers over SSE. A
# shared CI runner can stall the stream transport (the
# connection opening, or a mid-stream read) even when Studio
# is healthy, so harden the read three ways:
# * retry a transport stall once with a fresh request,
# capped at 300s (a healthy server answers a retry
# quickly, a wedged one never does);
# * return any text already streamed before a stall, so a
# stall on the trailing tokens -- after the answer
# arrived -- still counts;
# * when every attempt yields nothing, a hard call
# re-raises while a soft call (the best-effort
# server-side tool probes) returns None so the caller
# can WARN instead of sinking the whole job.
# HTTP status errors always surface immediately.
body = {**body, "stream": True}
data = json.dumps(body).encode()
req = urllib.request.Request(
@ -671,24 +704,43 @@ jobs:
"Content-Type": "application/json",
},
)
parts = []
with urllib.request.urlopen(req, timeout = timeout) as resp:
for raw in resp:
line = raw.decode().strip()
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
break
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
for choice in chunk.get("choices", []):
delta = choice.get("delta", {}) or {}
if delta.get("content"):
parts.append(delta["content"])
return "".join(parts)
for attempt in range(retries + 1):
parts = []
t = timeout if attempt == 0 else min(timeout, 300)
try:
with urllib.request.urlopen(req, timeout = t) as resp:
for raw in resp:
line = raw.decode().strip()
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
break
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
for choice in chunk.get("choices", []):
delta = choice.get("delta", {}) or {}
if delta.get("content"):
parts.append(delta["content"])
return "".join(parts)
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
# Text already streamed is a valid signal -- keep it
# rather than re-running a heavy generation.
if parts:
joined = "".join(parts)
print(f"[retry-sse] {path}: {exc!r}; keeping {len(joined)} partial chars", flush = True)
return joined
if attempt == retries:
if soft:
print(f"[tools] WARN {path}: SSE transport stalled with no data ({exc!r}) -- non-blocking", flush = True)
return None
raise
print(f"[retry-sse] {path}: {exc!r}", flush = True)
time.sleep(15)
# ── 1. Standard OpenAI function calling ──────────────────────
weather_tool = {
@ -731,6 +783,11 @@ jobs:
)
# ── 2. Server-side python tool ───────────────────────────────
# Bound each soft probe to a single 180s attempt (timeout=180,
# retries=0): this job runs two of them back-to-back under a
# 30-minute cap, so the default 600+15+300s per stall could hit
# the workflow timeout before the thinking checks run. A soft
# probe only WARNs anyway, so a retry buys nothing.
content = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}],
"enable_tools": True,
@ -739,8 +796,10 @@ jobs:
"temperature": TEMP,
"seed": SEED,
"max_tokens": 600,
})
if "56088" in content or "56,088" in content:
}, timeout = 180, retries = 0, soft = True)
if content is None:
print("[tools] WARN python tool: SSE transport stalled after retries -- non-blocking")
elif "56088" in content or "56,088" in content:
print(f"[tools] PASS python tool ({len(content)} chars, found 56088)")
else:
assert content, "python tool: SSE stream empty"
@ -762,8 +821,10 @@ jobs:
"temperature": TEMP,
"seed": SEED,
"max_tokens": 600,
})
if "hello-bash-tool" in content:
}, timeout = 180, retries = 0, soft = True)
if content is None:
print("[tools] WARN terminal tool: SSE transport stalled after retries -- non-blocking")
elif "hello-bash-tool" in content:
print(f"[tools] PASS terminal tool ({len(content)} chars)")
else:
assert content, "terminal tool: SSE stream empty"
@ -784,7 +845,7 @@ jobs:
"temperature": TEMP,
"seed": SEED,
"max_tokens": 400,
})
}, timeout = 180, retries = 0)
print(f"[tools] PASS web_search stream ({len(content)} chars)")
except Exception as exc:
print(f"[tools] WARN web_search probe failed (non-blocking): {exc}")
@ -1063,6 +1124,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 +1145,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 +1413,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 +1506,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 +1596,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 +1656,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 +1706,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:

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:

View file

@ -84,7 +84,7 @@ Use the same command to update.
```bash
unsloth studio -p 8888
```
For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally.
For LAN or cloud access, add `-H 0.0.0.0` (raw port only; add `--cloudflare` for a public URL). By default, Unsloth is accessible only locally.
To reach Studio over HTTPS, use `unsloth studio --secure`. Studio stays bound to localhost and is reached only through a free Cloudflare tunnel, which publishes it at a public `https://*.trycloudflare.com` URL (it fails closed if the tunnel can't start, so the raw port is never exposed). This makes Studio reachable from the internet, so anyone with the link and API key can use it and run code: keep your API key private (see Remote access below).
@ -212,10 +212,23 @@ 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 (subject to your firewall). It does not create a public internet URL; add `--cloudflare` to also publish an internet-reachable `https://*.trycloudflare.com` link even behind a firewall. Only use this on a network you trust.
```bash
unsloth studio -H 0.0.0.0 -p 8888
```
The Cloudflare tunnel is **off by default**: `-H 0.0.0.0` exposes the raw port only, not a public internet URL. Pair the wildcard bind with `--cloudflare` (`unsloth studio -H 0.0.0.0 --cloudflare`) to also publish a public `https://*.trycloudflare.com` link, or prefer `--secure` (above), which keeps the raw port private. `--cloudflare` has no effect on a loopback bind.
The first time Studio is published on a public URL (`--secure` or `--cloudflare`) with the auto-generated admin password still in place, it asks for a new admin password in the terminal (masked input with confirmation) before the public link goes up. Without an attached terminal it warns instead and keeps the bootstrap deadline: Studio shuts down after `UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT` (default 1 hour) unless the password is changed in the web UI.
For headless setups that cannot answer that prompt, set the initial admin password non-interactively with `--password` (only takes effect when no password is set yet; if one already exists it is a hard error, so rotate later with `unsloth studio reset-password`):
```bash
unsloth studio --secure --password 'your-strong-password' # visible in `ps`/history
UNSLOTH_STUDIO_PASSWORD='your-strong-password' unsloth studio --secure # via env var
printf '%s\n' 'your-strong-password' | unsloth studio --secure --password - # via stdin
```
A literal `--password VALUE` is visible in the process list and shell history, so prefer the `UNSLOTH_STUDIO_PASSWORD` env var or `--password -` (stdin) for automation. This applies to any launch (public or a headless `-H 0.0.0.0` bind), and the password is set in the parent before the server binds, so it never reaches a re-executed child process.
Server-side tools (web search, Python and terminal code execution) run as your user and are on by default. Anyone who can reach the server with the API key can run code on this machine, so keep your API key private and pass `--disable-tools` when exposing Studio.
@ -230,6 +243,14 @@ curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh
$env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex
```
Skip the post-install prompt that starts Studio (useful for automated installs):
```bash
curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_SKIP_AUTOSTART=1 sh
```
```powershell
$env:UNSLOTH_SKIP_AUTOSTART=1; irm https://unsloth.ai/install.ps1 | iex
```
Pin the Python version:
```bash
curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh

View file

@ -6,6 +6,7 @@
# irm | iex cannot forward arguments, so web installs take options as env vars set
# before the pipe (flags still work via .\install.ps1):
# $env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex # skip PyTorch (GGUF-only)
# $env:UNSLOTH_SKIP_AUTOSTART=1; irm https://unsloth.ai/install.ps1 | iex # do not prompt to launch
# $env:UNSLOTH_PYTHON='3.12'; irm https://unsloth.ai/install.ps1 | iex # pin Python version
# $env:UNSLOTH_STUDIO_HOME='C:\path'; irm https://unsloth.ai/install.ps1 | iex
# .\install.ps1 --no-torch # equivalent flag
@ -90,6 +91,7 @@ function Install-UnslothStudio {
if ($TauriMode) {
exit $Code
}
throw $Message
}
# ── Parse flags ──
@ -98,6 +100,7 @@ function Install-UnslothStudio {
$RepoRoot = ""
$TauriMode = $false
$SkipTorch = $false
$SkipAutostart = $false
$ShortcutsOnly = $false
$WithLlamaCppDir = ""
$argList = $args
@ -130,6 +133,7 @@ function Install-UnslothStudio {
# Env-var equivalent for web installs; an explicit flag still wins.
if ($env:UNSLOTH_NO_TORCH -in @('1', 'true', 'yes', 'on')) { $SkipTorch = $true }
if ($env:UNSLOTH_SKIP_AUTOSTART -in @('1', 'true', 'yes', 'on')) { $SkipAutostart = $true }
# Propagate to child processes so they also respect verbose mode.
# Process-scoped -- does not persist.
@ -469,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 {
@ -488,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] } } }
}
}
@ -2160,7 +2176,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.7.1" "unsloth-zoo>=2026.7.1" }
$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.3" "unsloth-zoo>=2026.7.3" }
if ($baseInstallExit -eq 0) {
# Resolve pydantic WITH deps so pip pins pydantic-core
# to the matching version (no-torch-runtime.txt below
@ -2174,7 +2190,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.7.1" "unsloth-zoo>=2026.7.1" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3" }
}
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
@ -2205,7 +2221,7 @@ exit 0
# ABI-incompatible torchvision/torchaudio on AMD's per-arch index.
$visionSpec = if ($ROCmGfxArch -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
$audioSpec = if ($ROCmGfxArch -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.
@ -2214,7 +2230,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 $TorchIndexUrl }
$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 $TorchIndexUrl }
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)
@ -2228,7 +2244,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)
@ -2240,7 +2256,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.7.1" "unsloth-zoo>=2026.7.1" }
$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.3" "unsloth-zoo>=2026.7.3" }
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 }
@ -2252,7 +2268,7 @@ exit 0
}
}
} elseif ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.7.1" "unsloth-zoo>=2026.7.1" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3" }
} else {
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth" { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
}
@ -2280,7 +2296,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.7.1" "unsloth>=2026.7.1" --torch-backend=auto }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.7.3" "unsloth>=2026.7.3" --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)
@ -2311,7 +2327,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
@ -2327,7 +2343,7 @@ exit 0
$visionSpec = if ($ROCmGfxArch -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
$audioSpec = if ($ROCmGfxArch -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)
@ -2336,7 +2352,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)
@ -2605,9 +2621,10 @@ exit 0
# Diagnostic only; never block install on a probe failure.
}
# In interactive terminals, ask the user before starting Studio.
# In interactive terminals, ask the user before starting Studio unless the
# caller explicitly disabled the post-install prompt.
# In non-interactive environments (CI, Docker) just print instructions.
$IsInteractive = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected)
$IsInteractive = (-not $SkipAutostart) -and [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected)
if ($IsInteractive) {
Write-Host ""
$reply = Read-Host " Start Unsloth Studio now? [Y/n]"
@ -2616,8 +2633,8 @@ exit 0
} else {
step "launch" "to start later, run:"
substep "unsloth studio -p 8888"
substep "(add -H 0.0.0.0 to allow network / cloud access)"
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)"
substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)"
Write-Host ""
}
} else {
@ -2637,8 +2654,8 @@ exit 0
substep "& $_actLiteral"
substep "unsloth studio -p 8888"
}
substep "(add -H 0.0.0.0 to allow network / cloud access)"
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)"
substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)"
Write-Host ""
}
}

View file

@ -8,8 +8,9 @@
#
# Piped installs take options as env vars after the pipe (a bare `| sh --no-torch`
# makes sh reject --no-torch as its own option). Flags still work via ./install.sh:
# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh # skip PyTorch (GGUF-only)
# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh # pin Python version
# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh # skip PyTorch (GGUF-only)
# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_SKIP_AUTOSTART=1 sh # do not prompt to launch
# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh # pin Python version
# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_STUDIO_HOME=/abs/path sh
# Equivalent flags: ./install.sh --no-torch --python 3.12 (or pipe them: sh -s -- --no-torch)
#
@ -49,6 +50,7 @@ PACKAGE_NAME="unsloth"
TAURI_MODE=false
_USER_PYTHON=""
_NO_TORCH_FLAG=false
_SKIP_AUTOSTART=false
_VERBOSE=false
_SHORTCUTS_ONLY=false
_next_is_package=false
@ -88,6 +90,7 @@ done
# Env-var equivalents for piped installs; an explicit flag still wins.
case "${UNSLOTH_NO_TORCH:-}" in 1|true|TRUE|yes|YES|on|ON) _NO_TORCH_FLAG=true ;; esac
case "${UNSLOTH_SKIP_AUTOSTART:-}" in 1|true|TRUE|yes|YES|on|ON) _SKIP_AUTOSTART=true ;; esac
[ -z "$_USER_PYTHON" ] && [ -n "${UNSLOTH_PYTHON:-}" ] && _USER_PYTHON="$UNSLOTH_PYTHON"
if [ "$_VERBOSE" = true ]; then
@ -159,6 +162,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=$?
@ -1625,6 +1634,7 @@ _maybe_reroute_strixhalo_to_2404() {
# Forward explicit ROCm-bootstrap consent (e.g. Tauri) so the child auto-enables the
# GPU instead of falling back to the desktop-app prompt path.
[ "${UNSLOTH_ROCM_WSL_AUTO:-0}" = "1" ] && _rr_exports="$_rr_exports; export UNSLOTH_ROCM_WSL_AUTO=1"
[ "$_SKIP_AUTOSTART" = true ] && _rr_exports="$_rr_exports; export UNSLOTH_SKIP_AUTOSTART=1"
_rr_args=""
[ "$PACKAGE_NAME" != "unsloth" ] && _rr_args="$_rr_args --package $(_rr_q "$PACKAGE_NAME")"
[ -n "$_USER_PYTHON" ] && _rr_args="$_rr_args --python $(_rr_q "$_USER_PYTHON")"
@ -2210,9 +2220,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.
_torch_index_repairable() {
@ -2726,7 +2736,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.7.1" "unsloth-zoo>=2026.7.1"
"unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3"
# 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.
@ -2741,7 +2751,7 @@ if [ "$_MIGRATED" = true ]; then
# 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.7.1" "unsloth-zoo>=2026.7.1" ${_MLX_LM_EXCLUDE_ARG:-}
"unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3" ${_MLX_LM_EXCLUDE_ARG:-}
fi
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
substep "overlaying local repo (editable)..."
@ -2764,7 +2774,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 torchaudio \
--index-url "$TORCH_INDEX_URL" \
--default-index "$TORCH_INDEX_URL" \
--force-reinstall
fi
;;
@ -2890,7 +2900,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 torchaudio \
--index-url "$TORCH_INDEX_URL"
--default-index "$TORCH_INDEX_URL"
else
substep "installing PyTorch from Radeon repo (${_RADEON_BASE_URL})..."
# Pass explicit wheel URLs so the matched trio is
@ -2913,18 +2923,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 torchaudio \
--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 torchaudio \
--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 torchaudio \
--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
@ -2945,7 +2955,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.7.1" "unsloth-zoo>=2026.7.1"
"unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3"
# 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
@ -2963,7 +2973,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.7.1" "unsloth-zoo>=2026.7.1"
--upgrade-package unsloth "unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3"
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 ${_ZOO_REF}..."
@ -2984,7 +2994,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 torchaudio \
--index-url "$TORCH_INDEX_URL" \
--default-index "$TORCH_INDEX_URL" \
--force-reinstall
fi
;;
@ -2995,7 +3005,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.7.1" "unsloth>=2026.7.1" --torch-backend=auto
run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.7.3" "unsloth>=2026.7.3" --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 ${_ZOO_REF}..."
@ -3019,14 +3029,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 torchaudio \
--index-url "$TORCH_INDEX_URL" \
--default-index "$TORCH_INDEX_URL" \
--reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio
_installed_torch_ver=$("$_VENV_PY" -c "import torch; print(torch.__version__)" 2>/dev/null || true)
_installed_torch_tag=""
@ -3037,7 +3047,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 torchaudio --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 torchaudio --default-index $TORCH_INDEX_URL --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio" "$C_WARN"
fi
fi
fi
@ -3237,9 +3247,10 @@ printf " ${C_TITLE}%s${C_RST}\n" "Unsloth Studio installed!"
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
echo ""
# In interactive terminals, ask the user before starting Studio.
# In interactive terminals, ask the user before starting Studio unless the
# caller explicitly disabled the post-install prompt.
# In non-interactive environments (Docker, CI, cloud-init) just print instructions.
if [ -t 1 ]; then
if [ "$_SKIP_AUTOSTART" != true ] && [ -t 1 ]; then
echo ""
printf " Start Unsloth Studio now? [Y/n] "
# No readable answer (closed/EOF tty) defaults to no; Enter is still yes.
@ -3275,8 +3286,8 @@ if [ -t 1 ]; then
*)
step "launch" "to start later, run:"
substep "unsloth studio -p 8888"
substep "(add -H 0.0.0.0 to allow network / cloud access)"
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)"
substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)"
echo ""
;;
esac
@ -3297,7 +3308,7 @@ else
substep "source $_li_act_q"
substep "unsloth studio -p 8888"
fi
substep "(add -H 0.0.0.0 to allow network / cloud access)"
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)"
substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)"
echo ""
fi

View file

@ -42,6 +42,7 @@ version = {attr = "unsloth.models._utils.__version__"}
include-package-data = true
[tool.setuptools.package-data]
unsloth_cli = ["codex_fallback_prompt.md"]
studio = [
"*.sh",
"*.ps1",
@ -73,7 +74,7 @@ triton = [
]
huggingfacenotorch = [
"unsloth_zoo>=2026.7.1",
"unsloth_zoo>=2026.7.3",
"wheel>=0.42.0",
"packaging",
"numpy",
@ -94,7 +95,7 @@ huggingfacenotorch = [
]
huggingface = [
"unsloth[huggingfacenotorch]",
"unsloth_zoo>=2026.7.1",
"unsloth_zoo>=2026.7.3",
"torchvision",
"unsloth[triton]",
]
@ -579,7 +580,7 @@ colab-ampere-torch220 = [
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
]
colab-new = [
"unsloth_zoo>=2026.7.1",
"unsloth_zoo>=2026.7.3",
"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",

File diff suppressed because one or more lines are too long

View file

@ -18,6 +18,10 @@ from utils.paths import auth_db_path, ensure_dir
DB_PATH = auth_db_path()
DEFAULT_ADMIN_USERNAME = "unsloth"
# Single source for the password policy; models/auth.py ChangePasswordRequest
# and the terminal prompt both enforce it. Keep the unsloth_cli mirror in sync.
MIN_PASSWORD_LENGTH = 8
# Plaintext bootstrap password file beside auth.db, deleted on first password
# change so the credential never lingers on disk.
_BOOTSTRAP_PW_PATH = DB_PATH.parent / ".bootstrap_password"
@ -79,11 +83,42 @@ def _load_bootstrap_password() -> Optional[str]:
def clear_bootstrap_password() -> None:
"""Delete the persisted bootstrap password file (called after password change)."""
"""Delete the persisted bootstrap password file (after a password change).
Best-effort: the new hash is already committed, so a locked/undeletable file
(Windows AV, read-only auth dir) must not fail the change.
"""
global _bootstrap_password
_bootstrap_password = None
if _BOOTSTRAP_PW_PATH.is_file():
_BOOTSTRAP_PW_PATH.unlink(missing_ok = True)
try:
_BOOTSTRAP_PW_PATH.unlink(missing_ok = True)
except OSError as e:
# Removal failed (Windows AV, read-only auth dir). The hash is already
# committed, so don't fail the change -- but truncate the file so its
# stale plaintext can't be re-seeded by generate_bootstrap_password()
# if a later reset-password deletes auth.db and re-validates it.
try:
_BOOTSTRAP_PW_PATH.write_text("")
cleared = True
except OSError:
cleared = False
import sys
if cleared:
message = (
f"Warning: could not delete {_BOOTSTRAP_PW_PATH.name} ({e}); "
"cleared its contents so the old bootstrap password cannot be reused."
)
else:
# Neither removed nor truncated: stale plaintext is still on disk
# and would be reused if auth.db is reset. Don't claim otherwise.
message = (
f"Warning: could not delete or clear {_BOOTSTRAP_PW_PATH.name} ({e}); "
"its old bootstrap password is still on disk. Remove it manually to "
"prevent reuse after a reset."
)
print(message, file = sys.stderr, flush = True)
def _hash_token(token: str) -> str:
@ -547,8 +582,18 @@ def ensure_default_admin() -> bool:
return False
def update_password(username: str, new_password: str) -> bool:
"""Update password, clear first-login requirement, rotate JWT secret."""
def update_password(
username: str,
new_password: str,
*,
revoke_refresh_tokens: bool = False,
) -> bool:
"""Update password, clear first-login requirement, rotate JWT secret.
``revoke_refresh_tokens`` deletes the user's refresh tokens in the SAME
transaction: a separate delete could fail after the password commit and
leave a pre-change token still able to mint access tokens.
"""
from .hashing import hash_password
salt, pwd_hash = hash_password(new_password)
@ -563,6 +608,8 @@ def update_password(username: str, new_password: str) -> bool:
""",
(salt, pwd_hash, jwt_secret, username),
)
if revoke_refresh_tokens and cursor.rowcount > 0:
conn.execute("DELETE FROM refresh_tokens WHERE username = ?", (username,))
conn.commit()
if cursor.rowcount > 0:
clear_bootstrap_password()

View file

@ -0,0 +1,282 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Interactive terminal prompt that forces a bootstrap password change before
Studio is exposed on a public Cloudflare URL (``--secure`` / ``--cloudflare``).
Masked input echoes one ``*`` per keystroke (unlike ``getpass``). Works on
Windows (``msvcrt``) and Linux/macOS (``termios``). All output goes to stderr so
redirected stdout never swallows the prompt.
Mirrored for the CLI at ``unsloth_cli/commands/_password_prompt.py`` (the CLI
cannot import the Studio backend package); keep the two in sync.
"""
from __future__ import annotations
import os
import sys
from typing import Callable, TextIO
_CTRL_C = "\x03"
_CTRL_D = "\x04"
_CTRL_Z = "\x1a"
_BACKSPACES = ("\x7f", "\x08")
_SUBMITS = ("\r", "\n")
# Env var that supplies the initial admin password non-interactively (mirror in
# unsloth_cli/commands/_password_prompt.py). Keep the name in sync.
SUPPLIED_PASSWORD_ENV = "UNSLOTH_STUDIO_PASSWORD"
def _getch_windows() -> str: # pragma: no cover - exercised via fake on Linux CI
import msvcrt
ch = msvcrt.getwch()
# Function/arrow keys arrive as a two-wchar \x00/\xe0 sequence; consume the
# second half and report a no-op control char.
if ch in ("\x00", "\xe0"):
msvcrt.getwch()
return "\x00"
return ch
class _RestoreTtyOnSignals:
"""Restore terminal attrs if SIGTERM/SIGHUP kills the prompt mid-read.
A finally block can't run when a signal terminates the process, leaving the
shared terminal in cbreak/no-echo. Best-effort: no-op off the main thread or
where the signals are absent.
"""
def __init__(self, fd: int, old_attrs) -> None:
self._fd = fd
self._old_attrs = old_attrs
self._previous: list = []
def __enter__(self) -> "_RestoreTtyOnSignals":
import signal
import termios
def _restore_and_reraise(signum, frame):
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_attrs)
signal.signal(signum, signal.SIG_DFL)
signal.raise_signal(signum)
for name in ("SIGTERM", "SIGHUP"):
sig = getattr(signal, name, None)
if sig is None:
continue
try:
self._previous.append((sig, signal.signal(sig, _restore_and_reraise)))
except (ValueError, OSError): # non-main thread / unsupported
pass
return self
def __exit__(self, *exc) -> None:
import signal
for sig, previous in self._previous:
try:
signal.signal(sig, previous)
except (ValueError, OSError):
pass
class _prompt_raw_mode:
"""Hold cbreak + cleared ISIG (no echo) on stdin for the WHOLE prompt line,
restoring when the line finishes (and on SIGTERM/SIGHUP).
Echo must never re-enable mid-line: cbreak echoes on receipt, so a keystroke
arriving while echo is on would appear in cleartext. One cbreak block for the
whole line closes that window. No-op when stdin is not a real terminal, so
the _getch seam can be faked in tests.
"""
def __enter__(self) -> "_prompt_raw_mode":
self._fd = None
self._old_attrs = None
self._signals = None
try:
import termios
import tty
except ImportError: # non-POSIX (Windows uses msvcrt, no mode to hold)
return self
try:
fd = sys.stdin.fileno()
old_attrs = termios.tcgetattr(fd)
except (AttributeError, ValueError, OSError, termios.error):
return self # redirected / captured stdin (tests): nothing to hold
self._fd = fd
self._old_attrs = old_attrs
self._signals = _RestoreTtyOnSignals(fd, old_attrs)
self._signals.__enter__()
# cbreak (not raw) keeps output post-processing while disabling echo/line
# buffering. It leaves ISIG on, so clear it and surface Ctrl-C as \x03 to
# the caller loop, which restores the tty itself.
tty.setcbreak(fd, termios.TCSADRAIN)
new_attrs = termios.tcgetattr(fd)
new_attrs[3] &= ~termios.ISIG
termios.tcsetattr(fd, termios.TCSADRAIN, new_attrs)
return self
def __exit__(self, *exc) -> None:
if self._old_attrs is None:
return
import termios
try:
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_attrs)
finally:
if self._signals is not None:
self._signals.__exit__(*exc)
def _getch_posix() -> str: # pragma: no cover - needs a real tty
# Terminal already in cbreak+no-echo for the whole line (_prompt_raw_mode),
# so just read. Byte-at-a-time incremental decode so a multi-byte UTF-8 char
# straddling a read boundary isn't dropped.
import codecs
fd = sys.stdin.fileno()
decoder = codecs.getincrementaldecoder(sys.stdin.encoding or "utf-8")("replace")
while True:
b = os.read(fd, 1)
if not b:
return "" # stream EOF; caller raises EOFError
ch = decoder.decode(b)
if ch:
return ch
_getch: Callable[[], str] = _getch_windows if os.name == "nt" else _getch_posix
def _read_password(prompt: str, *, out: "TextIO | None" = None) -> str:
"""Read one masked line: echo ``*`` per char, support backspace editing.
Raises KeyboardInterrupt on Ctrl-C and EOFError on Ctrl-D/Ctrl-Z with an
empty buffer; the terminal is restored on every exit path.
"""
if out is None:
out = sys.stderr
out.write(prompt)
out.flush()
chars: list[str] = []
with _prompt_raw_mode():
while True:
key = _getch()
if key == "": # stream ended mid-line: abort, don't submit a partial
out.write("\n")
out.flush()
raise EOFError
for ch in key: # a paste can deliver several chars per read
if ch in _SUBMITS:
out.write("\n")
out.flush()
return "".join(chars)
if ch == _CTRL_C:
out.write("\n")
out.flush()
raise KeyboardInterrupt
if ch in (_CTRL_D, _CTRL_Z):
if not chars:
out.write("\n")
out.flush()
raise EOFError
continue # ignore mid-input
if ch in _BACKSPACES:
if chars:
chars.pop()
out.write("\b \b")
out.flush()
continue
if ch < " ": # other control characters (tab, escape, ...)
continue
chars.append(ch)
out.write("*")
out.flush()
def should_prompt_password_change(
*, tunnel_will_start: bool, requires_change: bool, stdin_isatty: bool, stderr_isatty: bool
) -> bool:
"""Whether to block startup on an interactive terminal password change.
True only when the tunnel is actually about to start, the admin still has
the seeded password, and both stdin and stderr are real terminals (headless
launches keep the bootstrap-timeout protection instead of hanging).
"""
return tunnel_will_start and requires_change and stdin_isatty and stderr_isatty
def prompt_for_password_change(
*,
min_length: int,
is_current_password: Callable[[str], bool],
apply_change: Callable[[str], None],
username: str = "unsloth",
out: "TextIO | None" = None,
) -> bool:
"""Force a new admin password before public exposure; True on success.
Loops until a valid, confirmed password is committed via ``apply_change``.
Ctrl-C / EOF returns False; the caller must then abort the launch.
"""
if out is None:
out = sys.stderr
out.write(
"\n"
"Unsloth Studio will be exposed on the public internet, so set a\n"
"password now. Ctrl+C to abort.\n\n"
)
out.flush()
try:
while True:
new_password = _read_password("New password: ", out = out)
if len(new_password) < min_length:
out.write(f"Password must be at least {min_length} characters; try again.\n")
out.flush()
continue
if is_current_password(new_password):
out.write(
"New password must differ from the current bootstrap password; try again.\n"
)
out.flush()
continue
confirmation = _read_password("Confirm new password: ", out = out)
if confirmation != new_password:
out.write("Passwords do not match; try again.\n")
out.flush()
continue
apply_change(new_password)
out.write(f"Password updated for '{username}'.\n")
out.flush()
return True
except (KeyboardInterrupt, EOFError):
out.write("Password change aborted; not exposing Studio.\n")
out.flush()
return False
def resolve_supplied_password(cli_value: "str | None", out: "TextIO | None" = None) -> "str | None":
"""Resolve a non-interactive initial admin password, or None if unset.
Precedence: an explicit ``--password`` (literal ``-`` reads a line from
stdin), then the ``UNSLOTH_STUDIO_PASSWORD`` env var; empty/omitted means off.
A literal argv value is visible in the process list, so a note points at the
env var or stdin instead. Mirror of the CLI helper -- keep the two in sync.
"""
if out is None:
out = sys.stderr
if cli_value == "-":
line = sys.stdin.readline()
if not line:
return None
return line.rstrip("\r\n") or None
if cli_value:
out.write(
"Note: --password is visible in the process list and shell history; "
f"prefer {SUPPLIED_PASSWORD_ENV} or --password - (stdin).\n"
)
out.flush()
return cli_value
return os.environ.get(SUPPLIED_PASSWORD_ENV) or None

View file

@ -323,8 +323,8 @@ def start(port: int = 8888, *, cloudflare: bool = False):
logger.info(" Starting server...")
try:
# cloudflare=False: this helper owns the tunnel. run_server's default True
# would tunnel this 0.0.0.0 bind if Colab detection fails, breaking the opt-out.
# cloudflare=False: this helper owns the tunnel (Colab's own
# start(cloudflare=...) drives it), so pin it off explicitly.
app = run_server(
host = "0.0.0.0",
port = port,

View file

@ -9,6 +9,8 @@ import os
from pathlib import Path
from typing import Any
from utils.paths import recipe_datasets_root
from .jsonable import to_jsonable
from .local_callable_validators import (
register_oxc_local_callable_validators,
@ -277,6 +279,11 @@ def create_data_designer(recipe: dict[str, Any], *, artifact_path: str | None =
_apply_data_designer_image_context_patch()
from data_designer.interface.data_designer import DataDesigner # pyright: ignore[reportMissingImports]
if artifact_path is None:
# DataDesigner defaults to cwd/artifacts; packaged Studio can run with
# cwd=/, so keep default callers on Studio's writable recipe artifact root.
artifact_path = str(recipe_datasets_root())
recipe = _strip_frontend_model_config_metadata(recipe)
model_providers = build_model_providers(recipe)
_validate_recipe_runtime_support(recipe, model_providers)

View file

@ -132,6 +132,11 @@ class ExportOrchestrator:
"""True while an export / load / cleanup command is running."""
return self._export_active
def is_worker_alive(self) -> bool:
"""True while the persistent export subprocess is running (op or idle)."""
proc = self._proc
return proc is not None and proc.is_alive()
def was_cancelled(self) -> bool:
"""True if the in-flight (or most recent) run was cancelled by the user."""
return self._cancel_requested
@ -204,6 +209,23 @@ class ExportOrchestrator:
def _spawn_subprocess(self, config: dict) -> None:
"""Spawn a new export subprocess."""
# Last-resort recheck for spawns outside an active op. Inside an op, _export_active is set and
# load_checkpoint already rechecked, so a reservation here is an install about to observe
# is_export_active() and abort; raising would kill this export for an install that never proceeds.
from utils.transformers_version import sidecar_swap_in_progress
from utils.transformers_version import sidecar_swap_kind
_swap_kind = sidecar_swap_kind()
# Inside an active op an INSTALL reservation is about to abort on the
# is_export_active check, but a lazy REPAIR has no such check and can be
# rebuilding the sidecar right now, so it must always refuse the spawn.
if _swap_kind == "repair" or (_swap_kind is not None and not self._export_active):
from utils.transformers_version import SidecarSwapInProgress
raise SidecarSwapInProgress(
"A transformers installation is replacing the latest sidecar; "
"retry when it completes."
)
from utils.native_path_leases import (
native_path_secret_removed_for_child_start,
run_without_native_path_secret,
@ -231,11 +253,17 @@ class ExportOrchestrator:
adopt_pid(self._proc.pid) # bind to parent lifetime (Windows job / sweep)
logger.info("Export subprocess started (pid=%s)", self._proc.pid)
def _shutdown_subprocess(self, timeout: float = 10.0) -> None:
"""Gracefully shut down the export subprocess."""
def _shutdown_subprocess(self, timeout: float = 10.0) -> bool:
"""Gracefully shut down the export subprocess.
Returns True only once the worker is confirmed dead. If it survives
terminate/kill (e.g. wedged in an uninterruptible CUDA syscall that outlives
SIGKILL) the live handle is KEPT, not nulled, so is_worker_alive() and the
pre-swap liveness guard can still observe the survivor instead of a cleared
handle and refuse the destructive sidecar swap."""
if self._proc is None or not self._proc.is_alive():
self._proc = None
return
return True
self._drain_queue()
@ -265,10 +293,20 @@ class ExportOrchestrator:
except Exception:
pass
if self._proc is not None and self._proc.is_alive():
# Survived SIGKILL (uninterruptible syscall): keep the handle so callers
# and the pre-swap guard see a live worker rather than a nulled one.
logger.error(
"Export subprocess still alive after terminate/kill; "
"preserving its handle for the pre-swap liveness check"
)
return False
self._proc = None
self._cmd_queue = None
self._resp_queue = None
logger.info("Export subprocess shut down")
return True
def _cleanup(self):
"""atexit handler."""
@ -339,9 +377,10 @@ class ExportOrchestrator:
if rtype == "status":
message = resp.get("message", "")
logger.info("Export subprocess status: %s", message)
# Surface status in the live log panel for high-level progress.
# One structured export_progress line per phase (consolidated in the
# server log, like training/download progress); also shown live.
if message:
logger.info("export_progress", phase = message)
self._append_log(
{
"stream": "status",
@ -409,14 +448,44 @@ class ExportOrchestrator:
self._export_active = True
op_success, op_message = False, ""
try:
# Handshake with the sidecar install route: _export_active is set above, so either this
# recheck refuses BEFORE tearing down the old worker (keeping the loaded checkpoint), or
# the install sees is_export_active() and 409s. The spawn-time recheck stays as a last resort.
from utils.transformers_version import sidecar_swap_in_progress
if sidecar_swap_in_progress():
from utils.transformers_version import SidecarSwapInProgress
op_message = (
"A transformers installation is replacing the latest "
"sidecar; retry when it completes."
)
raise SidecarSwapInProgress(op_message)
# Always kill any existing subprocess and spawn fresh.
if self._ensure_subprocess_alive():
self._shutdown_subprocess()
if self._shutdown_subprocess() is False:
# Survivor still holds GPU memory (a wedged CUDA syscall outliving
# SIGKILL); its handle is kept so is_worker_alive() and the pre-swap
# guard still see it. Do not spawn a second worker over it -- fail so
# the load can retry once it exits.
op_message = (
"The current export worker did not exit and still holds GPU "
"memory; not starting a new checkpoint load over it. Retry shortly."
)
return False, op_message
elif self._proc is not None:
self._shutdown_subprocess(timeout = 2)
logger.info("Spawning fresh export subprocess for '%s'", checkpoint_path)
self._spawn_subprocess(sub_config)
try:
self._spawn_subprocess(sub_config)
except Exception:
# The old worker is already gone; a stale current_checkpoint
# would make the Export page claim a loaded checkpoint that
# the next op then fails on with "no subprocess running".
self.current_checkpoint = None
self.is_vision = False
self.is_peft = False
raise
try:
resp = self._wait_response("loaded")
@ -560,6 +629,18 @@ class ExportOrchestrator:
self._export_active = True
op_success, op_message, op_output_path = False, "", None
try:
# Handshake with the sidecar install route (see load_checkpoint): _export_active is set
# above, so this recheck refuses before the command is sent, or the install sees the active
# op and 409s. Without it, an install would block in cleanup_memory behind a long export op.
from utils.transformers_version import sidecar_swap_in_progress
if sidecar_swap_in_progress():
from utils.transformers_version import SidecarSwapInProgress
op_message = (
"A transformers installation is replacing the latest "
"sidecar; retry when it completes."
)
raise SidecarSwapInProgress(op_message)
cmd = {"type": "export", "export_type": export_type, **params}
try:
self._send_cmd(cmd)

View file

@ -236,6 +236,17 @@ def _handle_load(backend, cmd: dict, resp_queue: Any) -> None:
checkpoint_path = cmd["checkpoint_path"]
max_seq_length = cmd.get("max_seq_length", 2048)
load_in_4bit = cmd.get("load_in_4bit", True)
# Latest-sidecar checkpoints load 16-bit here too: bnb 4-bit feeds quantized
# expert weights into unvalidated paths (same flip as the chat worker).
if load_in_4bit:
from utils.transformers_version import latest_tier_active_for
if latest_tier_active_for(checkpoint_path, cmd.get("hf_token")):
load_in_4bit = False
logger.info(
"Latest-transformers sidecar active for %s - forcing a 16-bit "
"export load (4-bit is disabled for brand-new architectures)",
checkpoint_path,
)
trust_remote_code = cmd.get("trust_remote_code", False)
# Auto-enable trust_remote_code for NemotronH/Nano models.
@ -387,6 +398,19 @@ def _handle_export(backend, cmd: dict, resp_queue: Any) -> None:
# orchestrator spawns a fresh subprocess per checkpoint load, resetting it.
_log_forward_gate.set()
# Phase milestone so the heavy export step shows in the server log; the
# merge/save/convert itself only forwards stdout to the live panel.
_phase = {
"merged": f"Exporting merged model ({cmd.get('format_type', '16-bit (FP16)')})...",
"gguf": f"Exporting GGUF ({cmd.get('quantization_method', 'Q4_K_M')})...",
"lora": "Exporting LoRA adapter...",
"base": "Exporting base model...",
}.get(export_type, f"Exporting ({export_type})...")
_send_response(
resp_queue,
{"type": "status", "message": _phase, "ts": time.time()},
)
output_path: Any = None
try:
if export_type == "merged":

View file

@ -7,6 +7,11 @@ Minimal HTML-to-Markdown converter using only the standard library.
Replaces the external ``html2text`` (GPL-3.0) dependency with a ~250-line
``html.parser.HTMLParser`` subclass. Covers headings, links, bold/italic,
lists, tables, blockquotes, code blocks, and entity decoding.
``main_content=True`` also applies a readability-style heuristic: scope
conversion to the page's ``<article>`` (else ``<main>``) subtree when it
carries substantial text, and strip known boilerplate fragments (skip-links,
error placeholders, session banners, cookie prompts) from the result.
"""
from __future__ import annotations
@ -27,8 +32,138 @@ _SKIP_TAGS = frozenset(
"math",
"nav",
"footer",
# Never-rendered / form-chrome elements, not page content.
"template",
"dialog",
"button",
"select",
"datalist",
}
)
# <aside> is NOT skipped: docs use it for admonition callouts (real content);
# page-furniture asides are excluded by the main-content scoping pass instead.
# Void elements never produce an end tag, so they must not join the
# open-element stack used to bound hidden subtrees.
_VOID_TAGS = frozenset(
{
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
}
)
def _style_hides_element(style: str) -> bool:
"""True when an inline ``style`` sets ``display:none`` / ``visibility:hidden``.
Parsed per property so an unrelated value that merely contains ``none`` is
not misread as hidden."""
lowered = style.lower()
if "none" not in lowered and "hidden" not in lowered:
return False
for declaration in style.split(";"):
prop, sep, value = declaration.partition(":")
if not sep:
continue
prop = prop.strip().lower()
# Drop any !important flag and keep the first token of the value.
value = value.split("!", 1)[0].strip().lower()
if prop == "display" and value == "none":
return True
if prop == "visibility" and value == "hidden":
return True
return False
def _is_hidden_element(attr_dict: dict) -> bool:
"""True when the element is not rendered: ``hidden`` attribute,
``aria-hidden="true"``, or an inline ``style`` hiding it. Such JS-only
placeholders ship in the HTML but must not reach the output. ``hidden`` is
enumerated: any present value (even ``hidden="false"``) means not rendered."""
if "hidden" in attr_dict:
return True
if (attr_dict.get("aria-hidden") or "").strip().lower() == "true":
return True
return _style_hides_element(attr_dict.get("style") or "")
# HTML5 optional end tags: a listed start tag implicitly closes an open element
# of the key type (as browsers do), else an unclosed ``<p hidden>``/``<li hidden>``
# swallows every following sibling. Keys: closable elements; values: closers.
_P_CLOSING_TAGS = frozenset(
{
"address",
"article",
"aside",
"blockquote",
"details",
"div",
"dl",
"fieldset",
"figcaption",
"figure",
"footer",
"form",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"header",
"hgroup",
"hr",
"main",
"menu",
"nav",
"ol",
"p",
"pre",
"section",
"table",
"ul",
}
)
_IMPLICIT_CLOSERS: dict = {
"p": _P_CLOSING_TAGS,
"li": frozenset({"li"}),
"dt": frozenset({"dt", "dd"}),
"dd": frozenset({"dt", "dd"}),
"tr": frozenset({"tr"}),
"td": frozenset({"td", "th", "tr"}),
"th": frozenset({"td", "th", "tr"}),
"option": frozenset({"option", "optgroup"}),
"optgroup": frozenset({"optgroup"}),
}
# Item tag -> container tags that re-scope it: a nested container makes an inner
# item a descendant, not an optional-close sibling, so recovery must stop there
# rather than close (and un-hide) the outer item and leak its nested content.
_CLOSE_BARRIERS: dict = {
"li": frozenset({"ul", "ol", "menu"}),
"dt": frozenset({"dl"}),
"dd": frozenset({"dl"}),
"tr": frozenset({"table"}),
"td": frozenset({"table"}),
"th": frozenset({"table"}),
"option": frozenset({"select", "datalist"}),
"optgroup": frozenset({"select", "datalist"}),
}
_BLOCK_TAGS = frozenset(
{
"p",
@ -51,13 +186,33 @@ _INLINE_EMPHASIS = {"strong": "**", "b": "**", "em": "*", "i": "*"}
class _MarkdownRenderer(HTMLParser):
"""HTMLParser subclass that emits Markdown tokens into a list."""
"""HTMLParser subclass that emits Markdown tokens into a list.
def __init__(self):
``scope_tags`` restricts emission to the subtree(s) of the given tags
(e.g. ``{"article"}``): outside them every handler is a no-op, which is
how the readability-style main-content pass drops page furniture.
"""
def __init__(self, scope_tags: frozenset[str] | None = None):
super().__init__(convert_charrefs = False)
self._out: list[str] = []
self._skip_depth: int = 0
# Main-content scoping: emit only while inside a scope tag.
self._scope_tags = scope_tags
self._scope_depth: int = 0
# Output boundaries per top-level scope element, so a caller can size each
# candidate alone and a swarm of tiny sibling cards can't clear the threshold.
self.scope_segments: list[str] = []
self._scope_seg_start: int | None = None
# Hidden-subtree tracking: stack of open non-void tags plus the indices
# where a hidden element started. End tags pop to the matching tag, so
# an omitted </p>/<li> close cannot leave the renderer stuck hidden.
self._open_tags: list[str] = []
self._hidden_marks: list[int] = []
# Link state
self._link_href: str | None = None
self._link_text_parts: list[str] = []
@ -150,16 +305,95 @@ class _MarkdownRenderer(HTMLParser):
# ------------------------------------------------------------------
# Tag handlers
# ------------------------------------------------------------------
# Structural bookkeeping shared by every start tag (skip/hidden/scope).
def _close_implicit(self, tag: str) -> None:
"""HTML5 optional-end-tag recovery for a start tag about to open.
Pops each implicitly-closed ancestor (and its hidden marks), scanning the
whole stack so an open ``<p>``/``<li>`` still closes under an unclosed inline
``<span>``. Stops at a ``_CLOSE_BARRIERS`` container so recovery never crosses
a nested list/table/dl and leaks the outer item's hidden content. Runs even
for skipped ``<nav>``/``<footer>``, which also close ``<p>``."""
barriers = _CLOSE_BARRIERS.get(tag, ())
while True:
close_at = None
for i in range(len(self._open_tags) - 1, -1, -1):
name = self._open_tags[i]
if tag in _IMPLICIT_CLOSERS.get(name, ()):
close_at = i
break
# A barrier container re-scopes the item; stop before it.
if name in barriers:
break
if close_at is None:
break
del self._open_tags[close_at:]
while self._hidden_marks and self._hidden_marks[-1] >= close_at:
self._hidden_marks.pop()
def _enter_tag(self, tag: str, attr_dict: dict) -> bool:
"""Track open/hidden/scope state; return True when the tag's content
should be rendered (False = suppressed). Caller runs ``_close_implicit``
first so recovery also fires for skipped tags."""
if tag not in _VOID_TAGS:
self._open_tags.append(tag)
if _is_hidden_element(attr_dict):
self._hidden_marks.append(len(self._open_tags) - 1)
elif _is_hidden_element(attr_dict):
# Void elements never join the stack, so suppress a hidden one inline.
return False
if self._scope_tags is not None and tag in self._scope_tags:
if self._scope_depth == 0:
self._scope_seg_start = len(self._out)
self._scope_depth += 1
if self._hidden_marks:
return False
if self._scope_tags is not None and self._scope_depth == 0:
return False
return True
def _exit_tag(self, tag: str) -> bool:
"""Pop to the matching open tag; return True when the end tag should
be rendered (False = it closed inside a hidden / out-of-scope region)."""
suppressed = bool(self._hidden_marks) or (
self._scope_tags is not None and self._scope_depth == 0
)
if tag not in _VOID_TAGS:
# Pop to the innermost matching open tag (recovers omitted closes).
for i in range(len(self._open_tags) - 1, -1, -1):
if self._open_tags[i] == tag:
del self._open_tags[i:]
while self._hidden_marks and self._hidden_marks[-1] >= i:
self._hidden_marks.pop()
break
if self._scope_tags is not None and tag in self._scope_tags and self._scope_depth > 0:
self._scope_depth -= 1
if self._scope_depth == 0 and self._scope_seg_start is not None:
self.scope_segments.append("".join(self._out[self._scope_seg_start :]))
self._scope_seg_start = None
return not suppressed
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.lower()
if self._skip_depth:
# Inside a skipped subtree: only track nested skip depth.
if tag in _SKIP_TAGS:
self._skip_depth += 1
return
# Recover optional end tags before the skip decision: a skipped
# <nav>/<footer> still implicitly closes an open <p>, releasing its
# hidden mark so following siblings render.
self._close_implicit(tag)
if tag in _SKIP_TAGS:
self._skip_depth += 1
return
if self._skip_depth:
return
attr_dict = dict(attrs)
if not self._enter_tag(tag, attr_dict):
return
if tag in _HEADING_TAGS:
level = int(tag[1])
@ -250,6 +484,9 @@ class _MarkdownRenderer(HTMLParser):
if self._skip_depth:
return
if not self._exit_tag(tag):
return
if tag in _HEADING_TAGS:
self._emit("\n\n")
@ -308,8 +545,13 @@ class _MarkdownRenderer(HTMLParser):
# ------------------------------------------------------------------
# Text / entity handlers
# ------------------------------------------------------------------
def _text_suppressed(self) -> bool:
if self._skip_depth or self._hidden_marks:
return True
return self._scope_tags is not None and self._scope_depth == 0
def handle_data(self, data: str) -> None:
if self._skip_depth:
if self._text_suppressed():
return
if self._in_pre:
self._pre_parts.append(data)
@ -326,12 +568,12 @@ class _MarkdownRenderer(HTMLParser):
self._emit(text)
def handle_entityref(self, name: str) -> None:
if self._skip_depth:
if self._text_suppressed():
return
self._emit(html.unescape(f"&{name};"))
def handle_charref(self, name: str) -> None:
if self._skip_depth:
if self._text_suppressed():
return
self._emit(html.unescape(f"&#{name};"))
@ -366,6 +608,14 @@ class _MarkdownRenderer(HTMLParser):
else:
self._out.append("\n\n" + prefixed + "\n\n")
# A scope left open by truncated HTML never reached _exit_tag, so its output
# never joined scope_segments and would score 0. Flush the still-open segment
# here (after the side-buffers) so a truncated main-content page is scored.
if self._scope_seg_start is not None:
self.scope_segments.append("".join(self._out[self._scope_seg_start :]))
self._scope_seg_start = None
self._scope_depth = 0
# Post-processing
def _cleanup(text: str) -> str:
@ -399,17 +649,124 @@ def _cleanup(text: str) -> str:
return "\n".join(out).strip()
# Public API
def html_to_markdown(source_html: str) -> str:
"""Convert HTML to Markdown (headings, links, emphasis, lists, tables, blockquotes, code, entities).
# Known boilerplate fragments stripped from main-content conversions, matched
# only against short lines. Sources: GitHub page furniture / client-side error
# placeholders, skip-links, cookie banners.
_BOILERPLATE_FRAGMENTS = (
"skip to content",
"skip to main content",
"there was an error while loading",
"please reload this page",
"you can't perform that action at this time",
"you signed in with another tab or window",
"you signed out in another tab or window",
"you switched accounts on another tab or window",
"reload to refresh your session",
"you must be signed in to change notification settings",
"uh oh!",
"{{ message }}",
"this website uses cookies",
"we use cookies",
"accept all cookies",
"manage cookie preferences",
)
# Only shorter lines are eligible for boilerplate dropping; real content
# sentences quoting a fragment run longer.
_BOILERPLATE_MAX_LINE_CHARS = 300
``<script>``, ``<style>``, and ``<head>`` are stripped entirely.
"""
# Normalize line endings before parsing.
source_html = source_html.replace("\r\n", "\n").replace("\r", "\n")
renderer = _MarkdownRenderer()
# Normalized furniture phrases for whole-segment matching. See _line_is_boilerplate.
_BOILERPLATE_NORMALIZED = frozenset(
re.sub(r"\s+", " ", fragment).strip().casefold().rstrip(".!:")
for fragment in _BOILERPLATE_FRAGMENTS
)
def _line_is_boilerplate(line: str) -> bool:
"""True only when a whole line is composed of known furniture phrases.
Splits on sentence terminators and requires every segment to be furniture, so a
line stacking several phrases is dropped while prose that merely quotes one is
kept (its other words leave a non-furniture segment)."""
normalized = re.sub(r"\s+", " ", line).strip().casefold()
if not normalized:
return False
segments = [segment.strip().rstrip(".!:") for segment in re.split(r"[.!]", normalized)]
segments = [segment for segment in segments if segment]
return bool(segments) and all(segment in _BOILERPLATE_NORMALIZED for segment in segments)
def _strip_boilerplate_lines(text: str) -> str:
"""Drop short lines that consist entirely of known page-furniture phrases.
Fenced code blocks are preserved verbatim: boilerplate never renders
inside ``<pre>``, while READMEs legitimately quote error strings."""
out: list[str] = []
in_fence = False
for line in text.split("\n"):
if line.lstrip().startswith("```"):
in_fence = not in_fence
out.append(line)
continue
if not in_fence and len(line) <= _BOILERPLATE_MAX_LINE_CHARS and _line_is_boilerplate(line):
continue
out.append(line)
# Collapse blank runs the dropped lines may have left behind.
return re.sub(r"\n{3,}", "\n\n", "\n".join(out)).strip()
def _render(source_html: str, scope_tags: frozenset[str] | None) -> str:
renderer = _MarkdownRenderer(scope_tags = scope_tags)
renderer.feed(source_html)
renderer.close()
renderer.flush_pending()
raw = "".join(renderer._out)
return _cleanup(raw)
def _select_main_scope_render(source_html: str, tag: str) -> tuple[int, str]:
"""Length and boilerplate-stripped render of the largest single ``<tag>``
subtree. Sizing candidates one at a time stops many tiny sibling cards from
clearing the threshold together, and returning that one subtree keeps
unrelated siblings (related cards, comment threads) out of the output."""
renderer = _MarkdownRenderer(scope_tags = frozenset({tag}))
renderer.feed(source_html)
renderer.close()
renderer.flush_pending()
best_len = 0
best_render = ""
for seg in renderer.scope_segments:
rendered = _strip_boilerplate_lines(_cleanup(seg))
if len(rendered) > best_len:
best_len = len(rendered)
best_render = rendered
return best_len, best_render
# A scoped conversion below this size is judged not to be the page's main
# content (e.g. an empty <article> stub) and the next candidate is tried.
_MIN_MAIN_CONTENT_CHARS = 200
# Public API
def html_to_markdown(source_html: str, *, main_content: bool = False) -> str:
"""Convert HTML to Markdown (headings, links, emphasis, lists, tables, blockquotes, code, entities).
``<script>``, ``<style>``, and ``<head>`` are stripped entirely, as are
subtrees hidden from rendering (``hidden`` / ``aria-hidden="true"``).
``main_content=True`` applies a readability-style heuristic for page
fetches: prefer the ``<article>`` subtree (GitHub renders READMEs there),
then ``<main>``, falling back to the whole document, and strip known
boilerplate fragments from the result.
"""
# Normalize line endings before parsing.
source_html = source_html.replace("\r\n", "\n").replace("\r", "\n")
if main_content:
for scope_tag in ("article", "main"):
# Render only the chosen subtree so sibling <article>/<main>
# elements do not leak in once the largest passes the size gate.
length, rendered = _select_main_scope_render(source_html, scope_tag)
if length >= _MIN_MAIN_CONTENT_CHARS:
return rendered
return _strip_boilerplate_lines(_render(source_html, None))
return _render(source_html, None)

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

@ -12,6 +12,43 @@ 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__)

View file

@ -15,6 +15,7 @@ from pathlib import Path
from typing import Optional, Union, Generator, Tuple
from utils.models import ModelConfig, get_base_model_from_lora
from utils.paths import is_model_cached
from utils.transformers_dtype import dtype_kwargs
from utils.utils import format_error_message
from utils.hardware import (
get_device,
@ -440,7 +441,7 @@ class InferenceBackend:
feature_extractor = tokenizer.feature_extractor,
processor = tokenizer,
return_language = True,
torch_dtype = torch.float16,
**dtype_kwargs(torch.float16),
)
self.models[model_name]["model"] = model
self.models[model_name]["tokenizer"] = tokenizer
@ -832,6 +833,7 @@ class InferenceBackend:
nudge_tool_calls: Optional[bool] = None,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
thread_id: Optional[str] = None,
rag_scope: Optional[dict] = None,
presence_penalty: float = 0.0,
):
@ -885,6 +887,7 @@ class InferenceBackend:
max_tool_iterations = max_tool_iterations,
tool_call_timeout = tool_call_timeout,
session_id = session_id,
thread_id = thread_id,
rag_scope = rag_scope,
)
@ -1178,13 +1181,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
@ -1233,7 +1245,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
@ -1467,6 +1483,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
@ -1550,7 +1576,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

@ -4,11 +4,16 @@
from __future__ import annotations
import asyncio
import atexit
import concurrent.futures
import hashlib
import json
import os
import shlex
import sys
import threading
import time
import uuid
from typing import Any, Optional
from loggers import get_logger
@ -115,6 +120,18 @@ def join_stdio_command(parts: list[str]) -> str:
return shlex.join(parts)
def _stdio_log_id(url: str) -> str:
"""A non-secret label for logs. stdio commands can embed credentials in argv
(e.g. ``npx server --token sk-...``), so never log the raw command; use the
executable basename plus a short digest of the full command instead."""
try:
parts = parse_stdio_command(url)
exe = os.path.basename(parts[0]) if parts else "<empty>"
except Exception: # noqa: BLE001
exe = "<invalid>"
return f"{exe}#{hashlib.sha256(url.encode()).hexdigest()[:12]}"
def stdio_mcp_enabled() -> bool:
"""stdio MCP servers spawn local processes as the backend user (bypassing the
sandbox), so allowed only when the host is the user's own machine. On startup
@ -192,7 +209,6 @@ async def clear_oauth_tokens_async(url: str) -> None:
auth = OAuth(mcp_url = url, token_storage = _oauth_store())
await auth.token_storage_adapter.clear()
except Exception as exc: # noqa: BLE001
# Cleanup is best-effort; the row delete still wins.
logger.warning("Failed to clear OAuth tokens for %s: %s", url, exc)
@ -237,6 +253,502 @@ def _client(
return Client(transport_cls(url = url, headers = headers or None, auth = auth))
# Persistent stdio sessions: a stdio MCP server owns live state (a browser, a
# DB handle), so keep one connected client per (command, env, chat session) on
# a dedicated event-loop thread instead of respawning per call.
_STDIO_SESSION_IDLE_TTL = 300.0
_STDIO_SESSION_REAP_INTERVAL = 30.0
_STDIO_CONNECT_TIMEOUT = 60.0 # allows first-run `npx -y ...` package download
_STDIO_CLOSE_TIMEOUT = 10.0
_STDIO_WEDGE_MARGIN = 15.0
# Cap concurrent persistent sessions: each owns a subprocess + loop thread, and
# the scope includes a caller-supplied thread_id, so an unbounded cache is a
# resource-exhaustion surface. Overridable via env for large deployments.
try:
_STDIO_MAX_SESSIONS = max(1, int(os.environ.get("UNSLOTH_STUDIO_MAX_STDIO_MCP_SESSIONS", "32")))
except ValueError:
_STDIO_MAX_SESSIONS = 32
def _is_tool_error(exc: BaseException) -> bool:
"""A tool-level failure (the tool ran and errored) leaves the transport alive,
so the session is kept; fastmcp raises ToolError for these. Anything else from
call_tool is transport-level. Version-safe (fastmcp 3.0.2 has no dead probe)."""
try:
from fastmcp.exceptions import ToolError
except Exception: # noqa: BLE001
return False
return isinstance(exc, ToolError)
def _transport_dead(session) -> bool:
"""Best-effort, version-adaptive liveness probe for a cached stdio client.
``Client.is_connected()`` only checks a session object exists, not that the
subprocess is alive, so it is never used here. Returns True only when the
transport is positively gone; unknown returns False (the call surfaces it)."""
client = getattr(session, "client", None)
if client is None:
return True
transport = getattr(client, "transport", None)
probe = getattr(transport, "_is_session_dead", None)
if callable(probe):
try:
if probe():
return True
except Exception: # noqa: BLE001
pass
connect_task = getattr(transport, "_connect_task", None)
if connect_task is not None:
try:
if connect_task.done():
return True
except Exception: # noqa: BLE001
pass
return False
class _SessionWedged(Exception):
pass
class _SessionClosed(Exception):
"""The session was closed (server update/delete/shutdown) mid-call."""
def _abort_future(future) -> None:
# Let the cancelled coroutine unwind before its loop is stopped.
future.cancel()
try:
future.result(1.0)
except BaseException: # noqa: BLE001
pass
class _StdioSession:
def __init__(self, url: str, headers: Optional[dict]):
self.url = url
self.headers = headers
self.client = None
self.closed = threading.Event()
self.defunct = False # discarded; close once in_flight drains (see _retire)
self._close_lock = threading.Lock()
self.call_lock = threading.Lock() # serializes tool calls on this session
self.last_used = time.monotonic()
self.in_flight = 0 # guarded by _stdio_sessions_lock
# On Windows a bare new_event_loop() can be a SelectorEventLoop (if any
# component set that policy), which cannot spawn subprocesses natively;
# force a ProactorEventLoop so the stdio transport always works.
if sys.platform == "win32":
self.loop = asyncio.ProactorEventLoop()
else:
self.loop = asyncio.new_event_loop()
self._thread = threading.Thread(
target = self._run_loop, name = "mcp-stdio-session", daemon = True
)
self._thread.start()
def _run_loop(self) -> None:
asyncio.set_event_loop(self.loop)
try:
self.loop.run_forever()
finally:
self.loop.close()
def connect(self, timeout: Optional[float], cancel_event) -> None:
async def _open():
client = _client(self.url, self.headers)
await client.__aenter__()
# Publish on the loop thread with no await in between: if an abort
# races a just-completed connect, close() still sees the client and
# __aexit__s it instead of orphaning the subprocess.
self.client = client
return client
future = asyncio.run_coroutine_threadsafe(_open(), self.loop)
# timeout=None means unlimited (no connect deadline); a finite caller
# timeout still bounds connect by min(timeout, _STDIO_CONNECT_TIMEOUT).
window = None if timeout is None else min(timeout, _STDIO_CONNECT_TIMEOUT)
deadline = None if window is None else time.monotonic() + window
while True:
if cancel_event is not None and cancel_event.is_set():
_abort_future(future)
raise _MCPCancelled
try:
future.result(0.05)
return
except (concurrent.futures.TimeoutError, asyncio.TimeoutError):
if future.done():
raise # the connect itself failed fast; don't wait out the window
if deadline is not None and time.monotonic() >= deadline:
_abort_future(future)
raise asyncio.TimeoutError
def is_connected(self) -> bool:
client = self.client
if client is None:
return False
probe = getattr(client, "is_connected", None)
try:
return bool(probe()) if callable(probe) else True
except Exception:
return False
def run(self, coro, timeout: Optional[float]):
self.last_used = time.monotonic()
future = asyncio.run_coroutine_threadsafe(coro, self.loop)
# The coroutine enforces the tool timeout; the margin only catches a
# wedged loop. No deadline at all when the caller set none -- but poll
# so a session closed under us (server update/delete) can't hang the
# request thread forever on a stopped loop.
deadline = None if timeout is None else time.monotonic() + timeout + _STDIO_WEDGE_MARGIN
try:
while True:
try:
return future.result(0.25)
except concurrent.futures.CancelledError:
# Only close() cancels in-flight tasks (in _shutdown).
raise _SessionClosed
except (concurrent.futures.TimeoutError, asyncio.TimeoutError):
if future.done():
raise # the call's own timeout; the session stays usable
if self.closed.is_set():
future.cancel()
raise _SessionClosed
if deadline is not None and time.monotonic() >= deadline:
future.cancel()
raise _SessionWedged
finally:
self.last_used = time.monotonic()
def close(self) -> None:
# Idempotent: a discard racing close_stdio_sessions() may close twice.
# Setting `closed` first also unblocks run() waiters (they poll it).
with self._close_lock:
if self.closed.is_set():
return
self.closed.set()
loop = getattr(self, "loop", None)
loop_alive = loop is not None and not loop.is_closed()
if loop_alive:
async def _shutdown() -> None:
# Runs on the loop thread, so it serializes with an aborted
# connect() that finished anyway and just published its client.
client, self.client = self.client, None
if client is not None:
await client.__aexit__(None, None, None)
# Cancel in-flight calls so they unwind before loop.stop
# (their run() waiters have already been released via `closed`).
for task in asyncio.all_tasks():
if task is not asyncio.current_task():
task.cancel()
try:
asyncio.run_coroutine_threadsafe(_shutdown(), loop).result(_STDIO_CLOSE_TIMEOUT)
except Exception as exc: # noqa: BLE001
logger.warning(
"MCP stdio session close failed for %s: %s",
_stdio_log_id(getattr(self, "url", "")),
exc,
)
try:
loop.call_soon_threadsafe(loop.stop)
except RuntimeError:
pass
else:
self.client = None
thread = getattr(self, "_thread", None)
if thread is not None:
thread.join(timeout = 5.0)
_stdio_sessions: dict[tuple, _StdioSession] = {}
# Per-key locks so a slow connect/close never blocks unrelated servers; the
# global lock only guards the dicts.
class _StdioKeyLock:
"""A per-key lock that can be removed once nobody references it."""
def __init__(self) -> None:
self.lock = threading.Lock()
self.users = 0 # guarded by _stdio_sessions_lock
_stdio_key_locks: dict[tuple, _StdioKeyLock] = {}
_stdio_sessions_lock = threading.Lock()
_stdio_reaper_started = False
# close_stdio_sessions() can only close sessions already published in
# _stdio_sessions; one still inside connect() would be missed and cached
# stale. Bump a generation on every close so that connect discards its
# session instead of publishing it. Guarded by _stdio_sessions_lock.
_stdio_close_all_gen = 0
_stdio_url_close_gen: dict[str, int] = {}
_stdio_cfg_close_gen: dict[tuple, int] = {}
# close_stdio_sessions(url): match any env for that command.
_ANY_HEADERS = object()
def _headers_key(headers: Optional[dict]) -> tuple:
return tuple(sorted((headers or {}).items()))
def _url_close_key(url: str) -> str:
# Commands/URLs (token args, embedded credentials) and env values can hold
# secrets and these maps are never pruned; key by digest so closed/edited
# configs don't retain them in memory forever.
return hashlib.sha256(url.encode()).hexdigest()
def _cfg_close_key(url: str, headers: Optional[dict]) -> str:
return hashlib.sha256(repr((url, _headers_key(headers))).encode()).hexdigest()
def _stdio_close_generation(url: str, headers: Optional[dict]) -> tuple[int, int, int]:
return (
_stdio_close_all_gen,
_stdio_url_close_gen.get(_url_close_key(url), 0),
_stdio_cfg_close_gen.get(_cfg_close_key(url, headers), 0),
)
def _session_key(url: str, headers: Optional[dict], scope: Optional[str]) -> tuple:
return (url, _headers_key(headers), scope or "")
def _checkout_stdio_session(key: tuple) -> Optional[_StdioSession]:
session = _stdio_sessions.get(key)
if session is not None and session.is_connected():
session.last_used = time.monotonic()
session.in_flight += 1
return session
return None
def _borrow_stdio_key_lock(key: tuple) -> _StdioKeyLock:
"""Return a stable per-key lock while a caller waits for/connects it."""
key_lock = _stdio_key_locks.setdefault(key, _StdioKeyLock())
key_lock.users += 1
return key_lock
def _discard_stdio_key_lock(key: tuple) -> None:
key_lock = _stdio_key_locks.get(key)
if key_lock is not None and key_lock.users == 0 and key not in _stdio_sessions:
_stdio_key_locks.pop(key, None)
def _return_stdio_key_lock(key: tuple, key_lock: _StdioKeyLock) -> None:
with _stdio_sessions_lock:
key_lock.users -= 1
_discard_stdio_key_lock(key)
def _get_stdio_session(
url: str, headers: Optional[dict], scope: Optional[str], deadline, cancel_event, config_check
) -> _StdioSession:
"""``deadline`` is the caller's absolute monotonic budget (None = no limit):
the key-lock wait and the connect share it, so a slow startup can't stack
full timeout windows (see _call_stdio_tool)."""
global _stdio_reaper_started
key = _session_key(url, headers, scope)
with _stdio_sessions_lock:
session = _checkout_stdio_session(key)
if session is not None:
return session
key_lock = _borrow_stdio_key_lock(key)
try:
# Poll the acquire with connect()'s deadline/cancel semantics: a second
# same-scope call must not block uncancellably behind another caller's
# slow startup (e.g. a first-run npx download).
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
# timeout=None means no key-lock deadline (only cancel unblocks it).
window = None if remaining is None else min(remaining, _STDIO_CONNECT_TIMEOUT)
lock_deadline = None if window is None else time.monotonic() + window
while not key_lock.lock.acquire(timeout = 0.05):
if cancel_event is not None and cancel_event.is_set():
raise _MCPCancelled
if lock_deadline is not None and time.monotonic() >= lock_deadline:
raise asyncio.TimeoutError
try:
stale = None
with _stdio_sessions_lock:
session = _checkout_stdio_session(key)
if session is not None:
return session
if key in _stdio_sessions:
stale = _stdio_sessions.pop(key)
generation = _stdio_close_generation(url, headers)
if stale is not None:
_retire_stdio_session(stale)
session = _StdioSession(url, headers)
try:
session.connect(
None if deadline is None else max(0.0, deadline - time.monotonic()),
cancel_event,
)
except Exception:
session.close()
raise
# A caller can read the server row, then lose to an update/delete whose close ran
# before our generation snapshot. Re-verify the row after connect; the generation check
# below covers a close landing between this check and publish.
if config_check is not None:
try:
current = bool(config_check())
except Exception: # noqa: BLE001
current = False
if not current:
session.close()
raise RuntimeError("MCP server was updated or removed while connecting")
evicted: list = []
with _stdio_sessions_lock:
closed_while_connecting = _stdio_close_generation(url, headers) != generation
if not closed_while_connecting:
session.in_flight = 1
evicted = _evict_stdio_lru_locked() # bound the cache (LRU idle)
_stdio_sessions[key] = session
if not _stdio_reaper_started:
_stdio_reaper_started = True
threading.Thread(
target = _stdio_session_reaper, name = "mcp-stdio-reaper", daemon = True
).start()
atexit.register(close_stdio_sessions)
for victim in evicted:
logger.info("Evicting LRU idle stdio MCP session: %s", _stdio_log_id(victim.url))
victim.close()
if closed_while_connecting:
session.close()
raise RuntimeError("MCP server was updated or removed while connecting")
return session
finally:
key_lock.lock.release()
finally:
_return_stdio_key_lock(key, key_lock)
def _release_stdio_session(session: _StdioSession) -> None:
victims: list = []
with _stdio_sessions_lock:
session.in_flight = max(0, session.in_flight - 1)
session.last_used = time.monotonic()
close_now = session.defunct and session.in_flight == 0
# Re-enforce the cap once a burst's sessions go idle. Insert-time eviction
# only trims idle sessions, so it can overshoot while every cached session
# is busy; reclaim that overshoot here instead of waiting for the idle
# reaper. Never evict the session we just used (its last_used is newest).
while len(_stdio_sessions) > _STDIO_MAX_SESSIONS:
idle = [
(s.last_used, k)
for k, s in _stdio_sessions.items()
if s.in_flight == 0 and s is not session
]
if not idle:
break
_, oldest = min(idle, key = lambda item: item[0])
victims.append(_stdio_sessions.pop(oldest))
_discard_stdio_key_lock(oldest)
if close_now:
session.close()
for victim in victims:
victim.close()
def _retire_stdio_session(session: _StdioSession) -> None:
"""Close a discarded session, but only once no other borrower is mid-call
on it -- overlapping same-scope calls share one client, and one call's
timeout must not kill another's in-flight request. The last borrower's
_release_stdio_session() performs the deferred close."""
with _stdio_sessions_lock:
session.defunct = True
busy = session.in_flight > 0
if not busy:
session.close()
def _drop_stdio_session(key: tuple, session: _StdioSession) -> None:
with _stdio_sessions_lock:
if _stdio_sessions.get(key) is session:
_stdio_sessions.pop(key)
_discard_stdio_key_lock(key)
_retire_stdio_session(session)
def _evict_stdio_lru_locked() -> list:
"""Caller holds _stdio_sessions_lock. Evict least-recently-used *idle*
sessions until the cache is under the cap. Returns the evicted sessions so
the caller can close them OUTSIDE the lock. If every session is busy the
cache may transiently overshoot rather than kill an in-flight call."""
victims: list = []
while len(_stdio_sessions) >= _STDIO_MAX_SESSIONS:
idle = [(s.last_used, k) for k, s in _stdio_sessions.items() if s.in_flight == 0]
if not idle:
break
_, oldest = min(idle, key = lambda item: item[0])
victims.append(_stdio_sessions.pop(oldest))
_discard_stdio_key_lock(oldest)
return victims
def close_stdio_sessions(url: Optional[str] = None, headers = _ANY_HEADERS) -> None:
"""Close persistent stdio sessions: all of them (``url`` None), every env
for one command (``headers`` omitted), or one server config (url + headers).
Two server rows can share a command with different envs; editing one must
not kill the other's live state, so the routes pass the edited row's env."""
global _stdio_close_all_gen
# HTTP/SSE servers are never cached as stdio sessions, so a specific non-stdio
# url has nothing to close and must not accrue a close-generation entry.
if url is not None and not is_stdio(url):
return
hk = None if headers is _ANY_HEADERS else _headers_key(headers)
with _stdio_sessions_lock:
if url is None:
_stdio_close_all_gen += 1
elif hk is None:
uk = _url_close_key(url)
_stdio_url_close_gen[uk] = _stdio_url_close_gen.get(uk, 0) + 1
else:
cfg = _cfg_close_key(url, headers)
_stdio_cfg_close_gen[cfg] = _stdio_cfg_close_gen.get(cfg, 0) + 1
keys = [
k
for k in _stdio_sessions
if (url is None or k[0] == url) and (hk is None or k[1] == hk)
]
sessions = [_stdio_sessions.pop(k) for k in keys]
for key in keys:
_discard_stdio_key_lock(key)
for session in sessions:
session.close()
def _reap_idle_stdio_sessions(now: Optional[float] = None) -> None:
now = time.monotonic() if now is None else now
with _stdio_sessions_lock:
expired = [
key
for key, session in _stdio_sessions.items()
if session.in_flight == 0 and now - session.last_used >= _STDIO_SESSION_IDLE_TTL
]
sessions = [_stdio_sessions.pop(key) for key in expired]
for key in expired:
_discard_stdio_key_lock(key)
for session in sessions:
logger.info("Closing idle stdio MCP session: %s", _stdio_log_id(session.url))
session.close()
def _stdio_session_reaper() -> None:
while True:
time.sleep(_STDIO_SESSION_REAP_INTERVAL)
try:
_reap_idle_stdio_sessions()
except Exception as exc: # noqa: BLE001
logger.debug("stdio session reaper iteration failed: %s", exc)
async def list_tools_async(
url: str,
headers: Optional[dict] = None,
@ -251,11 +763,10 @@ async def list_tools_async(
return await asyncio.wait_for(_fetch(), timeout = timeout)
# Discovered-tool cache, keyed by MCP server id. get_enabled_mcp_tools()
# probes a server only on a cache miss, keeping MCP discovery off the chat
# send's critical path -- tool schemas are stable within a session. The
# /refresh route warms it; a URL/header/OAuth change or a delete evicts it.
# Successful probes are cached indefinitely.
# Discovered-tool cache, keyed by MCP server id. get_enabled_mcp_tools() probes a server only
# on a cache miss, keeping MCP discovery off the chat send's critical path -- tool schemas are
# stable within a session. The /refresh route warms it; a URL/header/OAuth change or a delete
# evicts it. Successful probes are cached indefinitely.
_tool_cache: dict[str, list[dict]] = {}
# server_id -> monotonic time before which a failed server must not be
@ -298,23 +809,210 @@ def invalidate_tool_cache(server_id: Optional[str] = None) -> None:
_probe_cooloff_until.pop(server_id, None)
MCP_IMAGES_SENTINEL = "__MCP_IMAGES__:"
MAX_IMAGE_PAYLOAD_CHARS = 12_000_000
def _flatten_result(result: Any) -> str:
parts = []
images = []
omitted = 0
budget = MAX_IMAGE_PAYLOAD_CHARS
for block in getattr(result, "content", None) or []:
text = getattr(block, "text", None)
if text:
parts.append(str(text))
continue
data = getattr(block, "data", None)
mime = getattr(block, "mimeType", None)
if data and isinstance(mime, str) and mime.startswith("image/"):
data = str(data)
if len(data) > budget:
omitted += 1
continue
budget -= len(data)
images.append({"data": data, "mimeType": mime})
body = "\n".join(parts)
if not body:
structured = getattr(result, "structured_content", None)
body = str(structured) if structured is not None else ""
if images or omitted:
notes = []
if images:
n = len(images)
notes.append(f"{n} image{'s' if n > 1 else ''} attached; displayed to the user")
if omitted:
notes.append(f"{omitted} image{'s' if omitted > 1 else ''} omitted (too large)")
note = f"[{'; '.join(notes)}]"
body = f"{body}\n{note}" if body else note
if getattr(result, "is_error", False):
# "Error: " prefix triggers tool_call_parser's TOOL_ERROR_PREFIXES nudge.
return f"Error: {body}" if body else "Error: tool returned no content"
body = f"Error: {body}" if body else "Error: tool returned no content"
if images:
body += "\n" + MCP_IMAGES_SENTINEL + json.dumps(images)
return body
async def _race_tool_call(call_coro, timeout: Optional[float], cancel_event) -> Any:
"""Await ``call_coro`` under ``timeout``, polling ``cancel_event`` so a
/cancel POST interrupts even mid-network-read."""
async def _watch_cancel() -> None:
while cancel_event is not None and not cancel_event.is_set():
await asyncio.sleep(0.05)
if cancel_event is not None and cancel_event.is_set():
call_coro.close()
raise _MCPCancelled
call_task = asyncio.create_task(call_coro)
if cancel_event is None:
return await asyncio.wait_for(call_task, timeout = timeout)
watch_task = asyncio.create_task(_watch_cancel())
try:
done, pending = await asyncio.wait(
{call_task, watch_task},
timeout = timeout,
return_when = asyncio.FIRST_COMPLETED,
)
finally:
for t in (call_task, watch_task):
if not t.done():
t.cancel()
if not done:
raise asyncio.TimeoutError
if call_task in done:
return call_task.result()
raise _MCPCancelled
def _call_stdio_tool(
url: str,
headers: Optional[dict],
name: str,
args: dict,
timeout,
cancel_event,
scope: Optional[str],
config_check,
) -> Any:
if cancel_event is not None and cancel_event.is_set():
raise _MCPCancelled
# One deadline covers the key-lock wait, connect, call-lock wait, and the
# call itself, matching the one-shot/HTTP paths where the timeout wrapped
# connect plus call in a single window.
deadline = None if timeout is None else time.monotonic() + timeout
def _remaining() -> Optional[float]:
return None if deadline is None else max(0.0, deadline - time.monotonic())
# Callers without a Studio session id must retain the former one-shot
# behavior: no browser/cookie/tool state can leak into another request.
# Use an ephemeral key (and close it below) rather than the shared empty
# scope that the persistent-session cache used previously.
def _config_ok() -> bool:
if config_check is None:
return True
try:
return bool(config_check())
except Exception: # noqa: BLE001
return False
ephemeral = not scope
if ephemeral:
scope = f"request-{uuid.uuid4().hex}"
key = _session_key(url, headers, scope)
# attempt 0 may find the cached session stale/dead *before* dispatch and
# reconnect once (safe); attempt 1 is a freshly connected session.
for attempt in (0, 1):
session = _get_stdio_session(url, headers, scope, deadline, cancel_event, config_check)
try:
# Serialize calls per session: overlapping same-scope calls must
# not interleave operations on one stateful server (browser, REPL).
while not session.call_lock.acquire(timeout = 0.05):
if cancel_event is not None and cancel_event.is_set():
raise _MCPCancelled
rem = _remaining()
if rem is not None and rem <= 0:
raise asyncio.TimeoutError
except BaseException:
# Never touched the transport: keep the session for its borrower.
_release_stdio_session(session)
if ephemeral:
_drop_stdio_session(key, session)
raise
discard_session = ephemeral
retry = False
try:
# We may have waited on the call lock while another caller's timeout retired this
# session, a server update/delete invalidated it, or a reused subprocess died. Re-check
# all three before dispatch so we never run on a retired/dead client or a stale config.
if session.closed.is_set():
# Intentional close (server update/delete/shutdown): don't retry on stale config.
discard_session = True
raise RuntimeError("MCP server was updated or removed during the call")
elif session.defunct:
# A concurrent same-scope caller's timeout retired this session;
# move to a fresh one instead of reusing the retired client.
discard_session = True
if attempt == 0:
retry = True
else:
raise RuntimeError("MCP server session was retired during the call")
elif not _config_ok():
discard_session = True
raise RuntimeError("MCP server was updated or removed during the call")
elif _transport_dead(session):
# Dead BEFORE dispatch: no request was sent, so reconnect + retry.
discard_session = True
if attempt == 0:
retry = True
else:
raise RuntimeError("MCP server connection is not available")
else:
rem = _remaining()
coro = _race_tool_call(session.client.call_tool(name, args), rem, cancel_event)
return session.run(coro, rem)
except (_MCPCancelled, asyncio.TimeoutError):
# _race_tool_call cancels the pending call but cancellation is
# cooperative. Never return this client to the cache while the
# timed-out/cancelled operation might still run on its transport.
discard_session = True
raise
except _SessionWedged:
discard_session = True
raise asyncio.TimeoutError
except _SessionClosed:
# close_stdio_sessions() shut this session mid-call (server
# update/delete/shutdown); don't retry on the stale config.
discard_session = True
raise RuntimeError("MCP server was updated or removed during the call")
except Exception as exc:
if session.closed.is_set():
# An intentional close (server update/delete) can surface as a plain transport
# error or AttributeError instead of _SessionClosed; don't mistake it for a crash.
discard_session = True
raise RuntimeError("MCP server was updated or removed during the call")
# ToolError leaves the transport alive -> keep the session so its state
# survives. Any other exception is transport-level (dead subprocess,
# broken pipe): evict so it can't poison the scope, but DO NOT replay
# (the tool may already have run); the next call opens a fresh session.
if not _is_tool_error(exc):
discard_session = True
raise
finally:
# Set defunct + remove from the cache BEFORE releasing the call lock,
# so a queued same-scope borrower observes the retirement and opens a
# fresh session instead of reusing this one.
_release_stdio_session(session)
if discard_session:
_drop_stdio_session(key, session)
session.call_lock.release()
if not retry:
break
raise RuntimeError("unreachable")
def call_tool_sync(
url: str,
headers: Optional[dict],
@ -323,55 +1021,35 @@ def call_tool_sync(
timeout: Optional[float] = 300.0,
use_oauth: bool = False,
cancel_event = None,
scope: Optional[str] = None,
config_check = None,
) -> str:
"""Synchronously call an MCP tool.
"""Synchronously call an MCP tool. stdio servers reuse a persistent session
keyed by (command, env, scope) only when ``scope`` is provided; calls
without one stay one-shot. HTTP servers always stay one-shot.
``cancel_event`` (threading.Event) cancels the in-flight call when set.
``config_check`` (callable -> bool) re-validates the caller's server config
before a fresh stdio session is cached; False fails the call."""
``cancel_event``: optional ``threading.Event``. When set, the in-flight call is
cancelled and a cancellation Error returned. Polled alongside the tool call via
``asyncio.wait`` so a /cancel POST interrupts even mid-network-read.
"""
async def _call() -> Any:
async def _one_shot() -> Any:
async with _client(url, headers, use_oauth) as client:
return await client.call_tool(name, args)
async def _watch_cancel() -> None:
# 50 ms cadence keeps cancellation responsive without busy-looping;
# matches routes/inference.py's cancel watcher cadence.
while cancel_event is not None and not cancel_event.is_set():
await asyncio.sleep(0.05)
async def _race() -> Any:
# Check cancellation before spawning the call task so a pre-set event
# short-circuits before opening the transport / HTTP connection.
if cancel_event is not None and cancel_event.is_set():
raise _MCPCancelled
call_task = asyncio.create_task(_call())
if cancel_event is None:
return await asyncio.wait_for(call_task, timeout = timeout)
watch_task = asyncio.create_task(_watch_cancel())
try:
done, pending = await asyncio.wait(
{call_task, watch_task},
timeout = timeout,
return_when = asyncio.FIRST_COMPLETED,
)
finally:
for t in (call_task, watch_task):
if not t.done():
t.cancel()
if not done:
raise asyncio.TimeoutError
if call_task in done:
return call_task.result()
raise _MCPCancelled
# raise_on_error=False lets an is_error result (which may still carry
# image content) reach _flatten_result instead of FastMCP raising ToolError
# and dropping the images. Transport failures still raise (handled below).
return await client.call_tool(name, args, raise_on_error = False)
try:
result = asyncio.run(_race())
if is_stdio(url):
result = _call_stdio_tool(
url, headers, name, args, timeout, cancel_event, scope, config_check
)
else:
result = asyncio.run(_race_tool_call(_one_shot(), timeout, cancel_event))
except _MCPCancelled:
return f"Error: MCP tool '{name}' cancelled"
except asyncio.TimeoutError:
return f"Error: MCP tool '{name}' timed out after {timeout:g}s"
suffix = f" after {timeout:g}s" if timeout is not None else ""
return f"Error: MCP tool '{name}' timed out{suffix}"
except Exception as exc:
logger.exception("MCP call_tool failed for %s: %s", name, exc)
return f"Error: MCP tool '{name}' failed: {exc}"

View file

@ -5,14 +5,120 @@ Drop-in replacement for InferenceBackend — same interface, uses mlx-lm/mlx-vlm
instead of torch/transformers for model loading and generation.
"""
import json
import os
import threading
from typing import Optional, Generator
from core.inference.message_content import content_to_text
from core.inference.runtime_context import runtime_context_length
from loggers import get_logger
logger = get_logger(__name__)
def _mlx_vlm_model_config(model):
"""Return the loaded MLX model config and its type, preferring whichever of
config / _config actually carries a model_type."""
def _model_type(cfg):
return cfg.get("model_type") if isinstance(cfg, dict) else getattr(cfg, "model_type", None)
configs = [
cfg
for cfg in (getattr(model, "config", None), getattr(model, "_config", None))
if cfg is not None
]
for cfg in configs:
model_type = _model_type(cfg)
if model_type is not None:
return cfg, model_type
return (configs[0] if configs else None), None
def _render_registered_vlm_prompt(processor, model, messages, num_images):
"""Render through mlx-vlm when it declares a formatter for this model."""
from mlx_vlm import prompt_utils
config, model_type = _mlx_vlm_model_config(model)
if config is None:
return None
if model_type not in getattr(prompt_utils, "MODEL_CONFIG", {}):
return None
rendered = prompt_utils.apply_chat_template(
processor,
config,
messages,
add_generation_prompt = True,
num_images = num_images,
)
if isinstance(rendered, str) and rendered.strip():
return rendered
raise RuntimeError("mlx-vlm's registered renderer returned an empty prompt.")
def _count_vlm_images(content):
if isinstance(content, list):
return sum(_count_vlm_images(item) for item in content)
if not isinstance(content, dict):
return 0
if str(content.get("type", "")).lower() in ("image", "image_url", "input_image"):
return 1
return _count_vlm_images(content.get("content"))
def _vlm_media_reprs(content):
if isinstance(content, list):
values = (
{str(content), json.dumps(content, ensure_ascii = False)}
if _count_vlm_images(content)
else set()
)
for item in content:
values.update(_vlm_media_reprs(item))
return values
if not isinstance(content, dict):
return set()
if str(content.get("type", "")).lower() in ("image", "image_url", "input_image"):
return {str(content), json.dumps(content, ensure_ascii = False)}
return _vlm_media_reprs(content.get("content"))
def _prompt_serializes_vlm_media(prompt, messages):
"""Detect templates that embed the exact structured media object repr."""
media_reprs = set()
for message in messages:
if isinstance(message, dict):
media_reprs.update(_vlm_media_reprs(message.get("content")))
text_content = [
content_to_text(message.get("content")) for message in messages if isinstance(message, dict)
]
return any(
prompt.count(media_repr) > sum(content.count(media_repr) for content in text_content)
for media_repr in media_reprs
)
def _vlm_prompt_issue(prompt, messages):
if not isinstance(prompt, str) or not prompt.strip():
return "an empty prompt"
if _prompt_serializes_vlm_media(prompt, messages):
return "serialized structured image content"
return None
def _vlm_messages_have_tool_history(messages):
return any(
isinstance(message, dict)
and (
message.get("role") == "tool"
or message.get("tool_calls")
or message.get("tool_call_id")
)
for message in messages
)
def _build_generation_stats(prompt_n, prompt_tps, gen_n, gen_tps):
"""Map mlx stream stats onto the usage/timings shape llama-server emits."""
prompt_n = int(prompt_n or 0)
@ -41,6 +147,48 @@ 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.
@ -52,7 +200,7 @@ def _make_mlx_presence_penalty_processor(penalty: float):
def _processor(tokens, logits):
if state["prompt_len"] is None:
# First call = prompt only; latch its length.
# First call is prompt-only; latch its length.
state["prompt_len"] = int(tokens.shape[0])
return logits
generated = tokens[state["prompt_len"] :]
@ -61,22 +209,17 @@ def _make_mlx_presence_penalty_processor(penalty: float):
import mlx.core as mx
vocab = logits.shape[-1]
# Bound generated ids to the valid range [0, vocab) before they index
# logits. MLX does no bounds checking and out-of-bounds indexing is
# documented undefined behavior (crash / memory corruption), unlike the
# torch path's harmless negative wrap -- so this bound is load-bearing
# here and matches the torch filter seen[(seen >= 0) & (seen < vocab)].
# MLX has no boolean-mask filtering (data-dependent output shape is
# unsupported), so instead of compacting the id list we route every
# out-of-range or negative id to a scratch slot at index ``vocab`` that
# is dropped before the subtract. That scratch slot can never collide
# with a real token, so real ids (including id 0) are penalized exactly
# once and stray ids are ignored.
# 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-assign a scalar penalty into a (vocab + 1)-wide mask: duplicate
# ids are idempotent, so presence applies once per distinct token; the
# scratch column is discarded and the full-width subtract stays on-device.
# 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]
@ -93,7 +236,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
@ -101,6 +244,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 = {}
@ -145,19 +291,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 later during generation.
# 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}': "
@ -176,11 +329,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
@ -190,14 +358,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:
@ -217,8 +394,7 @@ class MLXInferenceBackend:
self.models[model_name] = {
# Per-model token for the native-template fallback (matches transformers).
"hf_token": hf_token,
# Per-model consent for the native-template reload: re-use the exact
# trust_remote_code this model was loaded with (matches transformers).
# 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,
@ -234,8 +410,7 @@ class MLXInferenceBackend:
"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)
@ -293,6 +468,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()
@ -320,8 +498,7 @@ 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,
@ -334,7 +511,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})
@ -351,10 +527,7 @@ 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)
)
has_image = _count_vlm_images(content) > 0
if not has_image:
content.insert(0, {"type": "image"})
break
@ -415,6 +588,7 @@ class MLXInferenceBackend:
from core.inference.chat_template_helpers import (
apply_chat_template_for_generation,
detect_think_prefill,
render_with_native_template_fallback,
)
@ -429,11 +603,11 @@ class MLXInferenceBackend:
if prompt is None:
raise RuntimeError("apply_chat_template returned None — tokenizer may be incompatible")
# Same parity fix as 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 model_info tokenizer,
# so probe and native render share a renderer. (The VLM path renders via the
# processor for image tokens and is intentionally not wired here.)
# 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,
@ -448,6 +622,15 @@ class MLXInferenceBackend:
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,
@ -455,7 +638,7 @@ class MLXInferenceBackend:
min_p = float(min_p or 0.0),
min_tokens_to_keep = 1,
)
# Repetition and/or presence penalty processors (parity with the GGUF/safetensors paths).
# 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,
@ -496,12 +679,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
@ -544,8 +726,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
@ -554,28 +735,103 @@ class MLXInferenceBackend:
):
chat_target = getattr(self._processor, "tokenizer", self._processor)
prompt = apply_chat_template_for_generation(
chat_target,
messages,
tools = tools,
enable_thinking = enable_thinking,
reasoning_effort = reasoning_effort,
preserve_thinking = preserve_thinking,
)
# mlx_vlm's stream_generate handles pixel_values (None for text-only)
images = [image] if image is not None else None
attached_images = 0 if images is None else len(images)
structured_images = sum(
_count_vlm_images(message.get("content"))
for message in messages
if isinstance(message, dict)
)
if structured_images != attached_images:
raise RuntimeError(
f"VLM conversation contains {structured_images} structured image "
f"item(s) for {attached_images} attached image(s)."
)
prompt = None
has_tool_history = _vlm_messages_have_tool_history(messages)
prompt_error = None
try:
prompt = apply_chat_template_for_generation(
chat_target,
messages,
tools = tools,
enable_thinking = enable_thinking,
reasoning_effort = reasoning_effort,
preserve_thinking = preserve_thinking,
)
except Exception as exc:
if images is None or has_tool_history:
raise
prompt_error = exc
prompt_issue = (
_vlm_prompt_issue(prompt, messages) if prompt_error is None else "a rendering error"
)
if prompt_issue and has_tool_history:
raise RuntimeError(
f"VLM chat template returned {prompt_issue} and cannot be recovered "
"without dropping tool-call history."
) from prompt_error
cumulative = ""
if images is not None and prompt_issue:
if tools or any(
value is not None
for value in (enable_thinking, reasoning_effort, preserve_thinking)
):
if prompt_error is not None:
raise prompt_error
raise RuntimeError(
f"VLM chat template returned {prompt_issue} and cannot be recovered "
"without dropping requested tools or reasoning controls."
)
try:
recovered_prompt = _render_registered_vlm_prompt(
self._processor,
self._model,
messages,
len(images),
)
except Exception as recovery_error:
if prompt_error is not None:
raise prompt_error
raise RuntimeError(
f"VLM chat template returned {prompt_issue}; model-aware "
f"recovery failed: {recovery_error}"
) from recovery_error
if recovered_prompt is None:
if prompt_error is not None:
raise prompt_error
raise RuntimeError(
f"VLM chat template returned {prompt_issue}, and no registered "
"MLX VLM renderer was available for this model."
)
recovered_issue = _vlm_prompt_issue(recovered_prompt, messages)
if recovered_issue:
if prompt_error is not None:
raise prompt_error
raise RuntimeError(
f"Model-aware VLM rendering returned {recovered_issue} for "
f"{attached_images} attached image(s)."
)
prompt = recovered_prompt
elif prompt_issue:
raise RuntimeError(f"VLM chat template returned {prompt_issue}.") from prompt_error
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,
@ -589,7 +845,7 @@ class MLXInferenceBackend:
)
if presence_penalty:
# Presence needs a custom processor: pass the full list (repetition +
# presence) instead of the repetition_penalty shortcut so both apply once.
# presence) instead of the repetition_penalty shortcut so both apply.
from mlx_lm.sample_utils import make_logits_processors
_vlm_processors = []
@ -634,7 +890,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

@ -50,6 +50,18 @@ _DISPATCH_DRAIN_TIMEOUT = 5.0
_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:
"""
Inference backend orchestrator subprocess-based.
@ -162,6 +174,21 @@ class InferenceOrchestrator:
def _spawn_subprocess(self, config: dict) -> None:
"""Spawn a new inference subprocess."""
# Same recheck as the training/export spawns, REPAIR reservations only: a
# repair swaps without holding the lifecycle gate this load's caller owns,
# while an install cannot swap until this gate is released (and then its
# queued-load snapshot aborts it), so tolerating installs here lets the
# load win instead of failing both sides. Also covers the OpenAI
# auto-switch path, which enters _load_model_impl without route guards.
from utils.transformers_version import (
SidecarSwapInProgress,
sidecar_swap_kind,
)
if sidecar_swap_kind() == "repair":
raise SidecarSwapInProgress(
"A transformers repair is replacing the latest sidecar; retry when it completes."
)
from utils.native_path_leases import (
native_path_secret_removed_for_child_start,
run_without_native_path_secret,
@ -198,12 +225,24 @@ class InferenceOrchestrator:
if self._cancel_event is not None:
self._cancel_event.set()
def _shutdown_subprocess(self, timeout: float = 10.0) -> None:
"""Gracefully shut down the inference subprocess."""
def is_worker_alive(self) -> bool:
"""True while the inference subprocess is running, even with no model
active (a failed load can leave a live worker holding sidecar modules)."""
proc = self._proc
return proc is not None and proc.is_alive()
def _shutdown_subprocess(self, timeout: float = 10.0) -> bool:
"""Gracefully shut down the inference subprocess.
Returns True only once the worker is confirmed dead. If it survives
terminate/kill (e.g. wedged in an uninterruptible CUDA syscall that outlives
SIGKILL) the live handle is KEPT, not nulled, so is_worker_alive() and the
pre-swap liveness guard can still observe the survivor instead of a cleared
handle and refuse the destructive sidecar swap."""
self._stop_dispatcher() # before killing subprocess
if self._proc is None or not self._proc.is_alive():
self._proc = None
return
return True
# 1. Cancel any ongoing generation first (instant via mp.Event)
self._cancel_generation()
@ -240,12 +279,22 @@ class InferenceOrchestrator:
except Exception:
pass
if self._proc is not None and self._proc.is_alive():
# Survived SIGKILL (uninterruptible syscall): keep the handle so callers
# and the pre-swap guard see a live worker rather than a nulled one.
logger.error(
"Inference subprocess still alive after terminate/kill; "
"preserving its handle for the pre-swap liveness check"
)
return False
self._proc = None
self._cmd_queue = None
self._resp_queue = None
self._cancel_event = None
self._drain_event = None
logger.info("Inference subprocess shut down")
return True
def _cleanup(self):
"""atexit handler."""
@ -482,13 +531,13 @@ class InferenceOrchestrator:
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 f"Error: {self._subprocess_crash_message(crash_context)}"
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
@ -498,7 +547,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":
@ -513,7 +562,7 @@ 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
# ------------------------------------------------------------------
@ -640,11 +689,11 @@ 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
@ -655,7 +704,7 @@ class InferenceOrchestrator:
# 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 "Error: model is being unloaded"
yield GenStreamError("Error: model is being unloaded")
return
# Ensure the dispatcher runs. _start_dispatcher serializes concurrent starters under
@ -727,7 +776,7 @@ class InferenceOrchestrator:
# _stop_dispatcher joins the dispatcher, which itself takes that lock.
if orphaned_dispatcher:
self._stop_dispatcher()
yield "Error: model is being unloaded"
yield GenStreamError("Error: model is being unloaded")
return
try:
@ -735,7 +784,7 @@ class InferenceOrchestrator:
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):
@ -813,10 +862,70 @@ class InferenceOrchestrator:
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
# ------------------------------------------------------------------
# Monotonic count of PUBLISHED loads; lets the install route detect a load
# (including a same-model reload) that completed while it waited on the gate.
# Bumped when the load result is published, not at load start: a start-time
# bump is already visible when the installer snapshots mid-load, so the
# completed reload would look unchanged and get unloaded by the swap.
load_generation: int = 0
def load_model(
self,
config, # ModelConfig
@ -828,6 +937,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.
@ -853,6 +964,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,
@ -863,13 +979,36 @@ class InferenceOrchestrator:
sub_config["resolved_gpu_ids"] = resolved_gpu_ids
sub_config["gpu_selection"] = gpu_selection
# Recheck the sidecar reservation BEFORE tearing the old worker down,
# for REPAIRS only: an install holds this same lifecycle gate, so it
# cannot swap while this load runs, and its queued-load snapshot
# aborts it after this load publishes -- the load wins cleanly.
# Raising here (repair) keeps the current model loaded.
from utils.transformers_version import (
SidecarSwapInProgress,
sidecar_swap_kind,
)
if sidecar_swap_kind() == "repair":
raise SidecarSwapInProgress(
"A transformers repair is replacing the latest sidecar; "
"retry when it completes."
)
# Always kill the existing subprocess and spawn fresh: reusing one
# after unsloth patches torch internals breaks getsource on reload.
if self._ensure_subprocess_alive():
self._cancel_generation()
time.sleep(0.3)
self._shutdown_subprocess()
if self._shutdown_subprocess() is False:
# The worker survived terminate/kill (e.g. a wedged CUDA syscall that
# outlives SIGKILL). Its handle is kept, so is_worker_alive() and the
# pre-swap guard still see it; do not spawn a second worker over one
# still holding GPU memory. Fail so the load can retry once it exits.
raise RuntimeError(
"The current inference worker did not exit and still holds GPU "
"memory; not starting a new model over it. Retry shortly."
)
elif self._proc is not None:
self._shutdown_subprocess(timeout = 2)
@ -958,6 +1097,7 @@ class InferenceOrchestrator:
return False
model_info = resp.get("model_info", {})
self.active_model_name = model_info.get("identifier", model_name)
self.load_generation += 1
# 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
@ -989,8 +1129,15 @@ class InferenceOrchestrator:
self.models.clear()
raise Exception(error)
except Exception:
except Exception as exc:
self.loading_models.discard(model_name)
from utils.transformers_version import SidecarSwapInProgress
if isinstance(exc, SidecarSwapInProgress) and self._ensure_subprocess_alive():
# Raised before the old worker was torn down: the previous model
# is still live, so keep the mirrors (clearing them would let the
# installer treat the worker as inactive and kill it unreported).
raise
self.active_model_name = None
self.models.clear()
raise
@ -1221,9 +1368,11 @@ class InferenceOrchestrator:
nudge_tool_calls: Optional[bool] = None,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
thread_id: Optional[str] = None,
rag_scope: Optional[dict] = None,
confirm_tool_calls: bool = False,
bypass_permissions: bool = False,
permission_mode: Optional[str] = None,
use_adapter: Optional[Union[bool, str]] = None,
stats_holder: Optional[dict] = None,
presence_penalty: float = 0.0,
@ -1287,9 +1436,11 @@ class InferenceOrchestrator:
max_tool_iterations = max_tool_iterations,
tool_call_timeout = tool_call_timeout,
session_id = session_id,
thread_id = thread_id,
rag_scope = rag_scope,
confirm_tool_calls = confirm_tool_calls,
bypass_permissions = bypass_permissions,
permission_mode = permission_mode,
)
def generate_with_adapter_control(
@ -1338,11 +1489,11 @@ 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
@ -1359,7 +1510,7 @@ class InferenceOrchestrator:
# 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 "Error: model is being unloaded"
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
@ -1385,7 +1536,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(
@ -1544,10 +1695,10 @@ 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
@ -1556,7 +1707,7 @@ class InferenceOrchestrator:
# 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 "Error: model is being unloaded"
yield GenStreamError("Error: model is being unloaded")
return
request_id = str(uuid.uuid4())
@ -1583,7 +1734,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

@ -41,6 +41,9 @@ _HEAL_SIGNALS = (
"<|tool_call>",
"<function=",
"[TOOL_CALLS]",
# TML Inkling native call marker (leaks as text when the server-side
# parser misses a narration-then-call turn).
"<|content_invoke_tool_json|>",
)

View file

@ -15,6 +15,7 @@ parses tool calls from the cumulative text and dispatches via
"""
import bisect
import inspect
import re
import threading
from typing import Callable, Generator, Optional
@ -60,6 +61,7 @@ from core.inference.tool_loop_controller import (
status_for_tool,
tool_event_provenance,
)
from core.inference.tool_stream_exec import stream_tool_execution
from state.tool_approvals import (
TOOL_REJECTED_MESSAGE,
abort_tool_decision,
@ -402,6 +404,22 @@ def _tool_event_provenance(**flags: object) -> dict[str, object]:
return tool_event_provenance(**flags)
def _accepts_output_callback(func: Callable[..., str]) -> bool:
"""Whether an injectable ``execute_tool`` supports ``output_callback``.
The loop's ``execute_tool`` is a parameter (tests inject fakes), so forward
the live-output kwarg only when the callable declares it or takes ``**kwargs``.
"""
try:
sig = inspect.signature(func)
except (TypeError, ValueError):
return False
params = sig.parameters
if "output_callback" in params:
return True
return any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
def _call_single_turn(single_turn, conversation: list, active_tools: list[dict]):
"""Call a single-turn generator with active tool schemas when supported."""
try:
@ -424,9 +442,11 @@ def run_safetensors_tool_loop(
max_tool_iterations: int = 25,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
thread_id: Optional[str] = None,
rag_scope: Optional[dict] = None,
confirm_tool_calls: bool = False,
bypass_permissions: bool = False,
permission_mode: Optional[str] = None,
) -> Generator[dict, None, None]:
"""Drive an agentic tool loop on top of a cumulative-text generator.
@ -452,10 +472,27 @@ def run_safetensors_tool_loop(
"""
conversation = list(messages)
# Forced first-pass RAG (mirrors the GGUF loop) so doc Qs don't lose to web_search.
# Normalize the mode (mirrors the GGUF loop): "full" and
# bypass_permissions are the same switch; unset/unknown behaves as "ask".
# "off" keeps the sandbox but never prompts.
if permission_mode == "full":
bypass_permissions = True
elif bypass_permissions:
permission_mode = "full"
elif permission_mode not in ("ask", "auto", "off"):
permission_mode = "ask"
# Forced first-pass RAG (mirrors the GGUF loop) so doc Qs don't lose to
# web_search. Skip only when a retrieval call would actually prompt (ask
# mode); auto never gates the safe search_knowledge_base tool.
from core.inference.tools import build_rag_autoinject
_auto = None if confirm_tool_calls else build_rag_autoinject(conversation, rag_scope)
# off never prompts, so (like auto) it must not lose first-pass retrieval
# even if a direct caller passes a stale confirm_tool_calls flag.
_skip_autoinject = (
confirm_tool_calls and not bypass_permissions and permission_mode not in ("auto", "off")
)
_auto = None if _skip_autoinject else build_rag_autoinject(conversation, rag_scope)
if _auto:
for _ev in _auto["events"]:
yield _ev
@ -533,12 +570,24 @@ def run_safetensors_tool_loop(
provisional_render_html_started = False
provisional_resolved = False
provisional_render_html_id = f"call_{next_call_id}"
# Live-args offset for the provisional render_html card: the drained call
# text streams as tool_args so the canvas shows the HTML being written.
_live_args_streamed_upto = -1
# When a human confirmation gate is active the real tool_start is keyed
# by an approval id and carries awaiting_confirmation, so an early
# provisional card (keyed by tool_call_id, no approval) would show the
# tool as "running" before the user has approved it. Suppress the early
# card in that case and let the gated tool_start be the first signal.
_provisional_confirm_gated = bool(confirm_tool_calls) and not bypass_permissions
# In auto mode render_html is always safe and never prompts, so keep its
# early canvas card (the frontend sends confirm_tool_calls=true alongside
# auto); mirrors the GGUF path's _confirm_gated exemption.
from core.inference.tools import is_always_safe_tool
_provisional_confirm_gated = (
bool(confirm_tool_calls)
and not bypass_permissions
and not (permission_mode == "auto" and is_always_safe_tool("render_html"))
)
gen = _call_single_turn(single_turn, conversation, active_tools)
prev_cumulative = ""
@ -595,6 +644,30 @@ def run_safetensors_tool_loop(
"arguments": {},
"provenance": _tool_event_provenance(provisional = True),
}
# Backlog first: everything drained so far.
yield {
"type": "tool_args",
"tool_call_id": provisional_render_html_id,
"tool_name": "render_html",
"text": content_accum,
}
_live_args_streamed_upto = len(content_accum)
elif (
provisional_render_html_started
and not provisional_resolved
and _live_args_streamed_upto >= 0
and len(content_accum) > _live_args_streamed_upto
):
# Still writing the call: stream the fragment so the canvas
# renders live. Display only; content_accum still feeds the
# stream-end parser verbatim.
yield {
"type": "tool_args",
"tool_call_id": provisional_render_html_id,
"tool_name": "render_html",
"text": content_accum[_live_args_streamed_upto:],
}
_live_args_streamed_upto = len(content_accum)
continue
if detect_state == _state_streaming:
@ -635,6 +708,13 @@ def run_safetensors_tool_loop(
"arguments": {},
"provenance": _tool_event_provenance(provisional = True),
}
yield {
"type": "tool_args",
"tool_call_id": provisional_render_html_id,
"tool_name": "render_html",
"text": content_accum,
}
_live_args_streamed_upto = len(content_accum)
continue
cumulative_display = candidate
cleaned = strip_tool_markup_streaming(
@ -791,6 +871,13 @@ def run_safetensors_tool_loop(
"arguments": {},
"provenance": _tool_event_provenance(provisional = True),
}
yield {
"type": "tool_args",
"tool_call_id": provisional_render_html_id,
"tool_name": "render_html",
"text": content_accum,
}
_live_args_streamed_upto = len(content_accum)
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
@ -1055,8 +1142,17 @@ def run_safetensors_tool_loop(
assistant_msg.setdefault("tool_calls", []).append(decision.as_assistant_tool_call())
# Bypass wins over the confirm gate at the loop level too, so a
# direct internal caller passing both flags never prompts.
needs_confirm = bool(confirm_tool_calls) and not bypass_permissions
# direct internal caller passing both flags never prompts. In
# "auto" mode only calls detected as potentially unsafe pause.
# "off" never prompts (sandbox stays on).
needs_confirm = (
bool(confirm_tool_calls) and not bypass_permissions and permission_mode != "off"
)
if needs_confirm and permission_mode == "auto":
from core.inference.tools import is_potentially_unsafe_tool_call
needs_confirm = is_potentially_unsafe_tool_call(
decision.tool_name, decision.arguments
)
approval_id = new_approval_id() if needs_confirm else ""
decision_slot = begin_tool_decision(session_id, approval_id) if needs_confirm else None
start_event = decision.tool_start_event()
@ -1109,16 +1205,30 @@ def run_safetensors_tool_loop(
):
result = RAG_SEARCH_CAP_NUDGE
else:
try:
result = execute_tool(
decision.tool_name,
decision.arguments,
# Execute in a worker thread so live stdout chunks and heartbeats
# stream while the tool blocks (the SSE route turns heartbeats into
# keepalives). execute_tool is injectable; pass output_callback
# only when it accepts it.
def _invoke_tool(_output_callback, _decision = decision):
kwargs = dict(
cancel_event = cancel_event,
timeout = eff_timeout,
session_id = session_id,
thread_id = thread_id,
rag_scope = rag_scope,
disable_sandbox = bypass_permissions,
)
if _accepts_output_callback(execute_tool):
kwargs["output_callback"] = _output_callback
return execute_tool(_decision.tool_name, _decision.arguments, **kwargs)
try:
result = yield from stream_tool_execution(
_invoke_tool,
tool_name = decision.tool_name,
tool_call_id = decision.tool_call_id,
cancel_event = cancel_event,
)
except Exception as exc:
logger.exception("Tool %s raised: %s", decision.tool_name, exc)
result = f"Error: tool raised an exception: {exc}"

View file

@ -0,0 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
# Package marker only, so wheel builds ship this directory. It goes on the
# sandbox PYTHONPATH so site machinery imports the sibling ``sitecustomize`` at
# startup; nothing in the backend imports it directly.

View file

@ -0,0 +1,313 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Sandbox-side compatibility shim for ChatGPT code-interpreter paths.
Models habitually write to /mnt/data (or /mnt/outputs, /home/sandbox,
/workspace), none of which exist in the Studio sandbox. This module sits on the
sandbox subprocess PYTHONPATH (see ``tools._build_safe_env``), so it loads at
interpreter startup in every sandboxed ``python`` run and any Python the
``terminal`` tool launches.
It remaps those prefixes onto the CWD in ``open`` / ``io.open``, ``os.open``,
``os.makedirs`` / ``os.mkdir`` and ``pathlib.Path.mkdir``. A write/create to a
convention prefix always heals onto the CWD; a READ heals only when the mapped
target already exists (re-reading an earlier write), so a genuinely missing
input stays truthful on the path the model used instead of silently reading a
same-basename workdir file. Since prefix lists cannot cover every invented path,
``open`` / ``io.open`` also get a create-mode fallback: an absolute path outside
the CWD whose parent is missing is redirected to the basename in the CWD. Reads
and mkdir never use the fallback (an arbitrary absolute directory can legitimately
succeed). It is collision-safe: it refuses to redirect onto an existing CWD file
(letting open raise). The patch set (io.open, os.open, os.mkdir, Path.mkdir, and
the <3.11 ``_NormalAccessor.open``) covers the low-level entry points pathlib
routes through. A one-line stderr notice fires on the first remap, and everything
is wrapped in try/except so a failure never breaks the interpreter.
Identical with and without output streaming because the child env is.
"""
import builtins
import io
import json
import os
import sys
# Code-interpreter convention prefixes. Remapping is gated on the prefix being
# ABSENT (see _remap) so a genuine host mount / user dir is never shadowed.
_PREFIXES = ("/mnt/data", "/mnt/outputs", "/home/sandbox", "/workspace")
# /tmp exists on the host; separate only to note that. The absence gate applies alike.
_CONDITIONAL_PREFIXES = ("/tmp/outputs",)
_notified = False
# Invented absolute write path -> healed CWD target, so re-writing the same
# artifact re-serves it instead of tripping the anti-clobber guard.
_remapped_writes: dict = {}
# Each tool call is a fresh subprocess (in-process map starts empty), so this
# on-disk sidecar carries the map across runs. It records only sources the
# fallback healed, so an unrelated same-basename file is never adopted.
_REMAP_SIDECAR = ".unsloth_sandbox_remap.json"
def _note(subject, original, mapped):
"""Print the one-shot stderr notice so the model learns the real location.
``subject`` is what "does not exist" (the prefix, or the whole invented
path); ``original`` is echoed in the ``(original -> mapped)`` tail.
"""
global _notified
if _notified:
return
_notified = True
print(
f"note: {subject} does not exist in this sandbox; "
f"using the working directory instead ({original} -> {mapped})",
file = sys.stderr,
)
def _contained_join(cwd, rel):
"""Join ``rel`` onto ``cwd`` so the result can never escape ``cwd``.
A habit path can carry ``..`` segments; joining verbatim would let the target
climb above the sandbox. ``..`` components are dropped and empty / ``.`` ones
ignored, keeping the result under ``cwd``.
"""
parts = []
for part in rel.split("/"):
if part == "" or part == ".":
continue
if part == "..":
if parts:
parts.pop()
continue
parts.append(part)
return os.path.join(cwd, *parts) if parts else cwd
def _map_onto_cwd(
prefix,
text,
notify = True,
):
"""Map ``<prefix>/rest`` onto ``./rest`` in the CWD, noting it once.
The suffix is contained under the CWD (see ``_contained_join``) so a path
like ``/mnt/data/../other_session/file`` cannot escape the workdir.
``notify`` is False when the caller may keep the original path (a read), so
the one-shot notice is not spent on a remap that never happens.
"""
rel = text[len(prefix) :].lstrip("/")
mapped = _contained_join(os.getcwd(), rel)
if notify:
_note(prefix, text, mapped)
return mapped
def _sidecar_path(cwd):
return os.path.join(cwd, _REMAP_SIDECAR)
def _load_sidecar(cwd):
"""Return the persisted ``source -> healed target`` map, or {} on any error
(missing/corrupt/foreign sidecar degrades to in-process-only behaviour)."""
try:
with open(_sidecar_path(cwd)) as fh:
data = json.load(fh)
except Exception: # noqa: BLE001 - a bad sidecar must never break user code
return {}
return data if isinstance(data, dict) else {}
def _record_sidecar(cwd, source, target):
"""Persist ``source -> target`` so the next run re-serves it.
Written atomically (temp + ``os.replace``) and wrapped so a read-only/full
filesystem never breaks the interpreter. The path is inside the CWD, so the
patched ``open`` leaves it untouched (no remap, no recursion).
"""
try:
data = _load_sidecar(cwd)
if data.get(source) == target:
return
data[source] = target
tmp = _sidecar_path(cwd) + ".tmp"
with open(tmp, "w") as fh:
json.dump(data, fh)
os.replace(tmp, _sidecar_path(cwd))
except Exception: # noqa: BLE001 - persistence is best effort only
pass
def _is_creating_mode(mode):
"""True only when an ``open()`` mode string can CREATE a missing file.
Only ``w`` / ``a`` / ``x`` create. ``r+`` / ``rb+`` require the path to exist,
so they must not trip the write fallback (which would corrupt an unrelated
same-basename file); ``w+`` / ``a+`` / ``x+`` still match.
"""
return isinstance(mode, str) and any(c in mode for c in ("w", "a", "x"))
def _remap_open(file, mode):
"""Remap for ``open()`` / ``io.open()``.
A prefix remap runs first: a write/create heals onto the CWD; a READ heals
only when the mapped target already exists (re-reading an earlier write),
else the original path is kept so a genuine missing input fails truthfully
instead of silently reading a same-basename workdir file. Only if no prefix
matched and the call creates does the fallback kick in: an absolute target
outside the CWD whose parent is missing is redirected to the basename in the
CWD, unless ``CWD/<basename>`` already exists (an unrelated file), in which
case the original path is kept so open raises.
"""
creating = _is_creating_mode(mode)
# notify=False: emit the notice only once we commit to the mapping below.
mapped = _remap(file, notify = False)
if mapped is not file:
# Write always heals; a read only when the mapped target exists (else keep
# the original path so a missing input stays truthful).
if creating or os.path.exists(mapped):
# Commit: emit the notice now (the notify=False peek above deferred it).
_remap(file, notify = True)
return mapped
return file
if not creating:
return file
try:
text = os.fspath(file)
except TypeError:
return file
# bytes paths left untouched (str-only, matching the prefix remaps).
if not isinstance(text, str) or not os.path.isabs(text):
return file
cwd = os.getcwd()
# Already inside the CWD: a real target the model meant; leave it alone.
if text == cwd or text.startswith(cwd + os.sep):
return file
parent = os.path.dirname(text)
# Redirect only when the parent is missing; an existing external directory is
# a deliberate target and stays truthful (os.path.exists follows symlinks).
if parent and os.path.exists(parent):
return file
base = os.path.basename(text)
# A trailing sep or '.'/'..' basename would redirect onto the CWD or its
# parent; refuse and let open raise.
if base in ("", ".", ".."):
return file
remapped = os.path.join(cwd, base)
# Never clobber an unrelated file sharing this basename (lexists catches
# dangling symlinks). But a target this fallback already healed for the same
# invented path (in-process map or cross-run sidecar) is the artifact being
# re-written, so re-serve it instead of raising on every overwrite.
if os.path.lexists(remapped) and remapped not in (
_remapped_writes.get(text),
_load_sidecar(cwd).get(text),
):
return file
_remapped_writes[text] = remapped
_record_sidecar(cwd, text, remapped)
_note(text, text, remapped)
return remapped
def _remap(path, notify = True):
"""Map ``<prefix>/rest`` onto ``./rest`` in the CWD; other paths pass through.
``notify`` is forwarded to ``_map_onto_cwd``; ``_remap_open`` passes False so
a read that keeps its original path emits no false notice.
"""
try:
text = os.fspath(path)
except TypeError:
return path
if not isinstance(text, str):
return path
for prefix in _PREFIXES + _CONDITIONAL_PREFIXES:
# Heal only while the real prefix directory is absent, so a genuine host
# mount / user directory at that prefix is never shadowed.
if (text == prefix or text.startswith(prefix + "/")) and not os.path.exists(prefix):
return _map_onto_cwd(prefix, text, notify = notify)
return path
def _install():
import pathlib
original_open = builtins.open
original_io_open = io.open
original_os_open = os.open
original_makedirs = os.makedirs
original_mkdir = os.mkdir
original_path_mkdir = pathlib.Path.mkdir
def _open(
file,
mode = "r",
*args,
**kwargs,
):
return original_open(_remap_open(file, mode), mode, *args, **kwargs)
def _io_open(
file,
mode = "r",
*args,
**kwargs,
):
return original_io_open(_remap_open(file, mode), mode, *args, **kwargs)
# mkdir/makedirs get only the prefix remap, never the write-mode fallback:
# an arbitrary absolute directory can legitimately succeed on the host.
def _makedirs(name, *args, **kwargs):
return original_makedirs(_remap(name), *args, **kwargs)
def _mkdir(path, *args, **kwargs):
return original_mkdir(_remap(path), *args, **kwargs)
def _os_open(
path,
flags,
mode = 0o777,
*,
dir_fd = None,
):
# Path.touch() etc. go through os.open, not builtins.open. Only O_CREAT
# can create, so only it maps to "creating" mode; O_TRUNC / O_APPEND
# without O_CREAT still require the file to exist, so behave as a read.
logical_mode = "w" if (flags & os.O_CREAT) else "r"
mapped = _remap_open(path, logical_mode)
if dir_fd is None:
return original_os_open(mapped, flags, mode)
return original_os_open(mapped, flags, mode, dir_fd = dir_fd)
def _path_mkdir(self, *args, **kwargs):
# pathlib probes Path.is_dir()/os.stat (unpatched) on FileExistsError, so
# a bare os.mkdir remap would still raise when the target exists. Remap
# the receiver up front so parents/exist_ok stays idempotent.
mapped = _remap(self)
target = self if mapped is self else self.__class__(mapped)
return original_path_mkdir(target, *args, **kwargs)
builtins.open = _open
# pathlib.Path.open / write_text / read_text call io.open directly, so patch both.
io.open = _io_open
# Python < 3.11 only: pathlib's accessor captured the ORIGINAL io.open at
# import (``_NormalAccessor.open = io.open``), so the io.open patch misses it.
# Repoint it at the same wrapper (staticmethod to stay unbound); 3.11+ dropped
# the accessor, so this is a no-op there.
accessor = getattr(pathlib, "_NormalAccessor", None)
if accessor is not None and hasattr(accessor, "open"):
accessor.open = staticmethod(_io_open)
# Path.touch() and other low-level opens call os.open directly, so patch it too.
os.open = _os_open
os.makedirs = _makedirs
# Path.mkdir(parents=True) calls os.mkdir per component, so patch os.mkdir;
# patch Path.mkdir itself too so exist_ok/parents land on the mapped path.
os.mkdir = _mkdir
pathlib.Path.mkdir = _path_mkdir
try:
_install()
except Exception: # noqa: BLE001 - a broken shim must never break user code
pass

View file

@ -54,6 +54,8 @@ TOOL_XML_SIGNALS = (
# Kimi K2 / Moonshot.
"<|tool_calls_section_begin|>",
"<|tool_call_begin|>",
# TML Inkling native call marker.
"<|content_invoke_tool_json|>",
)

View file

@ -233,9 +233,33 @@ def is_tool_error(result: str) -> bool:
return isinstance(result, str) and result.lstrip().startswith(TOOL_ERROR_PREFIXES)
def _strip_mcp_image_suffix(result: str) -> str:
"""Drop a trailing __MCP_IMAGES__ envelope only when it is the valid JSON
image array appended by _flatten_result, so legit tool text that merely
mentions the marker is not truncated."""
head, sep, payload = result.rpartition("\n__MCP_IMAGES__:")
if not sep:
return result
try:
images = json.loads(payload)
except (ValueError, RecursionError):
return result
if not isinstance(images, list) or not images:
return result
if not all(
isinstance(img, dict)
and isinstance(img.get("data"), str)
and isinstance(img.get("mimeType"), str)
for img in images
):
return result
return head.rstrip()
def strip_result_for_model(result: str) -> str:
"""Remove frontend-only sentinels (image paths, RAG source map) before
feeding the result back to the model."""
result = _strip_mcp_image_suffix(result)
for sentinel in ("__IMAGES__:", "__RAG_SOURCES__:"):
if sentinel in result:
result = result.split(sentinel, 1)[0].rstrip()

View file

@ -0,0 +1,284 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Streaming wrapper around blocking server-side tool execution.
``stream_tool_execution`` runs a blocking tool call in a worker thread and
turns it into a generator that yields:
* ``{"type": "tool_output", "tool_name", "tool_call_id", "text"}`` -- an
incremental stdout/stderr chunk (python/terminal tools) for live UI output;
* ``{"type": "heartbeat"}`` -- emitted whenever nothing else has been yielded
for ``heartbeat_interval_s`` seconds, so the SSE route can write a
keepalive and reverse proxies (Cloudflare tunnels cap idle streams at
~100 s) never see a silent connection while a tool runs;
and *returns* the tool's final result string via ``StopIteration.value``
(``result = yield from stream_tool_execution(...)``). The returned result is
byte-identical to calling the tool directly, so tool-result parsing, nudging,
and healing downstream are untouched.
"""
from __future__ import annotations
import inspect
import queue
import threading
import time
from typing import Any, Callable, Generator
from loggers import get_logger
logger = get_logger(__name__)
def accepts_output_callback(func: Callable[..., str]) -> bool:
"""Whether an injectable ``execute_tool`` supports ``output_callback``.
``execute_tool`` is replaceable (tests inject fakes / the pre-PR signature),
so forward the kwarg only when the callable declares it or takes ``**kwargs``
(passing it unconditionally would ``TypeError`` on an old signature).
"""
try:
params = inspect.signature(func).parameters
except (TypeError, ValueError):
return False
if "output_callback" in params:
return True
return any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
# Cadence of heartbeat events while a tool blocks with no output. Well under
# common proxy idle caps (Cloudflare ~100 s, nginx default 60 s).
TOOL_HEARTBEAT_INTERVAL_S = 10.0
# How often the wrapper wakes to poll for output / completion / cancellation.
_POLL_INTERVAL_S = 0.25
# Upper bound on how long teardown waits for the worker once the stream is
# closed or errors. A cancel-observing tool returns within this after
# ``cancel_event`` is set; a cancel-ignoring one is a daemon left to finish on
# its own rather than blocking teardown for the tool's full timeout.
_WORKER_JOIN_TIMEOUT_S = 5.0
# Cap on total streamed live-output characters per tool call, bounding the
# transient UI stream so a tight print loop cannot flood the SSE channel. Much
# higher than the model-visible result cap (tools._MAX_OUTPUT_CHARS) since the
# UI keeps the live stream as the displayed output when the result is truncated.
TOOL_OUTPUT_STREAM_MAX_CHARS = 400_000
_STREAM_CAPPED_NOTICE = "\n... (further live output not streamed)\n"
def _drain_queue(q: "queue.Queue", sentinel: object, max_chars: int | None) -> tuple[str, bool]:
"""Pull every currently-queued item, joining chunks in FIFO order.
With ``max_chars`` set, stop concatenating at the budget and discard the
remaining chunks in place, bounding peak allocation when a chatty tool queues
far more than the cap before the consumer wakes. The crossing chunk is sliced
to one char past the budget, enough for the caller's truncation to stay
byte-identical. Returns ``(joined_text, hit_sentinel)``; the surplus is still
scanned so completion is detected promptly.
"""
parts: list[str] = []
total = 0
dropping = False
hit_sentinel = False
while True:
try:
item = q.get_nowait()
except queue.Empty:
break
if item is sentinel:
hit_sentinel = True
break
if dropping:
continue
if max_chars is not None and total + len(item) > max_chars:
# Keep one char past the budget as the overflow signal; drop the rest.
parts.append(item[: max(0, max_chars - total) + 1])
dropping = True
continue
parts.append(item)
total += len(item)
return "".join(parts), hit_sentinel
def stream_tool_execution(
invoke: Callable[[Callable[[str], None]], str],
*,
tool_name: str,
tool_call_id: str = "",
cancel_event: Any = None,
heartbeat_interval_s: float = TOOL_HEARTBEAT_INTERVAL_S,
poll_interval_s: float = _POLL_INTERVAL_S,
) -> Generator[dict, None, str]:
"""Run ``invoke(output_callback)`` in a thread; yield live events; return the result.
``invoke`` receives a thread-safe ``callable(str)`` it may call with
incremental output chunks (or ignore entirely). Exceptions raised by the
tool propagate to the caller unchanged after the worker thread finishes.
``cancel_event`` is the request-level cancellation signal already handed to
the tool. If the consumer closes this generator early (an SSE disconnect
calls ``gen.close()``, raising ``GeneratorExit`` at a ``yield``), the wrapper
sets it so a cancel-observing tool stops, then joins the worker with a bounded
timeout. Set ONLY on that abnormal-exit path, never on a clean finish, because
the event is shared across a turn's tool calls and setting it early would
abort the next tool.
"""
output_queue: queue.Queue[Any] = queue.Queue()
done_sentinel = object()
outcome: dict[str, Any] = {}
# Bound accepted output at the PRODUCER boundary: the consumer-side cap alone
# wouldn't stop a fast worker enqueuing unboundedly while a slow SSE client
# backpressures. Accept at most one char past the cap (so the consumer still
# emits the capped notice) and drop the rest. The final result is captured
# independently, so this never changes the byte-identical result.
accepted_output_chars = 0
accepted_output_lock = threading.Lock()
def _on_output(text: str) -> None:
nonlocal accepted_output_chars
if not text:
return
with accepted_output_lock:
remaining = TOOL_OUTPUT_STREAM_MAX_CHARS + 1 - accepted_output_chars
if remaining <= 0:
return
accepted = text[:remaining]
accepted_output_chars += len(accepted)
output_queue.put(accepted)
def _run() -> None:
try:
outcome["result"] = invoke(_on_output)
except BaseException as exc: # noqa: BLE001 - re-raised on the caller side
outcome["error"] = exc
finally:
# Posted after the result/error is recorded; wakes the consumer
# immediately so fast tools pay no poll-interval latency.
output_queue.put(done_sentinel)
worker = threading.Thread(
target = _run,
daemon = True,
name = f"tool-exec-{tool_name or 'unknown'}",
)
worker.start()
# Heartbeats are paced by counting idle queue polls rather than a wall clock
# (tests patch ``time.monotonic`` globally, so the wrapper must not read it).
idle_polls_per_heartbeat = max(1, int(round(heartbeat_interval_s / poll_interval_s)))
idle_polls = 0
streamed_chars = 0
stream_capped = False
finished = False
def _drain_pending(max_chars: int | None = None) -> str:
nonlocal finished
text, hit_sentinel = _drain_queue(output_queue, done_sentinel, max_chars)
if hit_sentinel:
finished = True
return text
def _drain_and_drop() -> None:
"""Discard the current and every queued chunk without concatenating.
Past the cap every chunk is dropped, so don't pay to build a combined
string only to drop it. Still detect completion so the loop can exit.
"""
nonlocal finished
while True:
try:
item = output_queue.get_nowait()
except queue.Empty:
return
if item is done_sentinel:
finished = True
return
abnormal_exit = False
try:
while not finished:
try:
item = output_queue.get(timeout = poll_interval_s)
except queue.Empty:
# A disconnect sets cancel_event while the worker is silent;
# surface a heartbeat this poll so the route regains control and
# tears down at once, not after a full heartbeat interval.
if cancel_event is not None and cancel_event.is_set():
yield {"type": "heartbeat"}
continue
idle_polls += 1
if idle_polls >= idle_polls_per_heartbeat:
idle_polls = 0
yield {"type": "heartbeat"}
continue
if item is done_sentinel:
break
if stream_capped:
# Past the cap: drop this chunk and every queued sibling (see
# _drain_and_drop). Pace with one time.sleep per poll (not
# time.monotonic -- tests patch the clock), counted as an idle
# poll so heartbeats keep flowing while the queue stays non-empty.
_drain_and_drop()
if finished:
break
time.sleep(poll_interval_s)
idle_polls += 1
if idle_polls >= idle_polls_per_heartbeat:
idle_polls = 0
yield {"type": "heartbeat"}
continue
# Bound the join to the remaining budget so the crossing batch can't
# allocate far past the cap (surplus is truncated below anyway); the
# prefix is long enough that truncation stays byte-identical.
budget = TOOL_OUTPUT_STREAM_MAX_CHARS - streamed_chars
chunk = item + _drain_pending(max_chars = budget - len(item))
idle_polls = 0
if streamed_chars + len(chunk) > TOOL_OUTPUT_STREAM_MAX_CHARS:
chunk = chunk[: max(0, TOOL_OUTPUT_STREAM_MAX_CHARS - streamed_chars)]
chunk += _STREAM_CAPPED_NOTICE
stream_capped = True
streamed_chars += len(chunk)
if chunk:
yield {
"type": "tool_output",
"tool_name": tool_name,
"tool_call_id": tool_call_id,
"text": chunk,
}
except BaseException:
# The loop only raises when the consumer closes us early: an SSE
# disconnect calls gen.close() (GeneratorExit at the yield) or the route
# throws in. Signal cancellation so a cancel-observing tool returns; the
# daemon worker is then abandoned (see finally). Re-raise so the caller
# sees the real cause (GeneratorExit must not be swallowed). Runs ONLY on
# abnormal exit, so the shared cancel_event is never set out from under
# the next tool in a clean multi-tool turn.
abnormal_exit = True
if cancel_event is not None:
try:
cancel_event.set()
except Exception:
pass
raise
finally:
# Clean finish: the worker already recorded its result and queued the
# sentinel we consumed, so this join returns at once. Abnormal exit:
# cancel_event is set and the daemon worker abandoned, so join with a zero
# timeout -- teardown never blocks the caller (the route may close this
# generator on the event loop), and the daemon cannot outlive the process.
worker.join(timeout = 0 if abnormal_exit else _WORKER_JOIN_TIMEOUT_S)
error = outcome.get("error")
if error is not None:
raise error
# Returned verbatim (the loop's record_result handles non-str), so the
# final tool result is byte-identical to a direct execute_tool call.
return outcome.get("result")

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
@ -276,6 +291,18 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
hf_token = _clean_token(config.get("hf_token"))
load_in_4bit = _resolve_lora_4bit(mc, config.get("load_in_4bit", True))
# Latest-transformers sidecar models load 16-bit: bnb 4-bit feeds quantized
# expert weights into unvalidated paths (e.g. grouped-MoE torch._grouped_mm).
if load_in_4bit:
from utils.transformers_version import latest_tier_active_for
if latest_tier_active_for(config["model_name"], hf_token):
load_in_4bit = False
logger.info(
"Latest-transformers sidecar active for %s - forcing a 16-bit "
"load (4-bit is disabled for brand-new architectures)",
config["model_name"],
)
trust_remote_code = config.get("trust_remote_code", False)
if not trust_remote_code and _needs_nemotron_trust(config["model_name"], hf_token = hf_token):
trust_remote_code = True
@ -329,14 +356,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()
@ -521,6 +552,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", "")
@ -720,9 +812,29 @@ def run_inference_process(
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..."},
@ -764,6 +876,8 @@ def run_inference_process(
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)
@ -804,7 +918,31 @@ def run_inference_process(
)
return
# ── Resolve the effective base once, before activation/gates/install (no ML import) ──
# ── Windows: check Triton availability ──
# Placed ahead of the torchao stub below (which imports torch on win32 to detect ROCm),
# matching the training and export workers' gate-then-stub ordering.
if sys.platform == "win32":
try:
import triton # noqa: F401
logger.info("Triton available — torch.compile enabled")
except ImportError:
os.environ["TORCHDYNAMO_DISABLE"] = "1"
logger.warning(
"Triton not found on Windows — torch.compile disabled. "
'Install for better performance: pip install "triton-windows<3.7"'
)
# ── Stub torchao on Windows ROCm before ANY transformers import ──
# Must precede every path that pulls transformers, not just the ML imports in section 2:
# a local LoRA adapter with no recorded base reaches transformers here via
# _resolve_base_model -> utils.models. See core/_torchao_stub.py; no-op off Windows ROCm.
from core._torchao_stub import install_torchao_windows_rocm_stub
install_torchao_windows_rocm_stub()
# ── Resolve the effective base once, before activation/gates/install ──
# No ML import on the common path; a local adapter with no recorded base pulls
# transformers via utils.models, which is why the stub above precedes this.
# A remote LoRA's base is in its Hub adapter_config.json (else surfaced only by ModelConfig
# after import). _lora_base is set only for a genuine adapter, never a full fine-tune's base.
import json as _json
@ -842,19 +980,7 @@ def run_inference_process(
)
return
# ── 1b. Windows: check Triton availability (must precede import torch) ──
if sys.platform == "win32":
try:
import triton # noqa: F401
logger.info("Triton available — torch.compile enabled")
except ImportError:
os.environ["TORCHDYNAMO_DISABLE"] = "1"
logger.warning(
"Triton not found on Windows — torch.compile disabled. "
'Install for better performance: pip install "triton-windows<3.7"'
)
# ── 1c. Security gates, then SSM/Mamba kernels, BEFORE importing transformers ──
# ── 1b. Security gates, then SSM/Mamba kernels, BEFORE importing transformers ──
# transformers snapshots its optional-backend gates at import, so a hybrid model's kernels
# must be installed before the import below ("mamba-ssm is required" otherwise). The gates
# are metadata-only, so run them first and refuse a blocked model before any native build.
@ -977,6 +1103,9 @@ def run_inference_process(
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

@ -21,6 +21,7 @@ from functools import lru_cache
from typing import Callable
from utils.hardware.hardware import DeviceType, get_device
from utils.transformers_dtype import dtype_kwargs
from . import config
@ -157,9 +158,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"}
)
_model = SentenceTransformer(name, device = device, model_kwargs = dtype_kwargs("float16"))
_name = name
return _model

View file

@ -124,10 +124,12 @@ def apply_tool_strip_patterns(
# Pre-compiled patterns for tool-call XML parsing.
_TC_JSON_START_RE = re.compile(r"<tool_call>\s*\{")
# <|content_invoke_tool_json|> is TML Inkling's native call marker; its JSON uses
# an ``args`` key and the block closes with <|end_message|>.
_TC_JSON_START_RE = re.compile(r"(?:<tool_call>|<\|content_invoke_tool_json\|>)\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_END_TAG_RE = re.compile(r"</tool_call>|<\|end_message\|>")
_TC_GEMMA_END_TAG_RE = re.compile(r"<tool_call\|>")
_TC_FUNC_CLOSE_RE = re.compile(r"\s*</function>\s*$")
# Horizontal-whitespace trailing class keeps the wrapping newline; _trim_param_value trims it.
@ -686,12 +688,24 @@ def parse_tool_calls_from_text(
if kind == "json":
obj = json.loads(content[m.end() - 1 : brace_end + 1])
name = obj.get("name", "")
# Accept ``parameters`` alias for ``arguments`` (Llama-3.2 drift inside Hermes).
# Accept ``parameters`` alias for ``arguments`` (Llama-3.2 drift inside
# Hermes) and ``args`` (TML Inkling native calls).
arguments = obj.get("arguments")
if arguments is None:
arguments = obj.get("parameters", {})
arguments = obj.get("parameters")
if arguments is None:
arguments = obj.get("args", {})
if isinstance(arguments, dict):
arguments = json.dumps(arguments)
# Inkling echoes the bare tool name (and a role opener) before the
# marker: <|message_model|>NAME<|content_invoke_tool_json|>{...}.
# Fold that echo into the markup span so promotion removes it too.
if name and content.startswith("<|content_invoke_tool_json|>", start):
pre = content[:start]
if pre.endswith(name):
start -= len(name)
if content[:start].endswith("<|message_model|>"):
start -= len("<|message_model|>")
else:
name = m.group(1)
arguments = json.dumps(_gemma_arguments_to_json(content[m.end() : brace_end]))

View file

@ -11,8 +11,10 @@ import os
import sys
import types
# Prevent tokenizer parallelism deadlocks when datasets forks.
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# Off on Linux so datasets' forked map() workers can't deadlock. On spawn platforms
# (Windows/macOS) map() runs in-process, so keep the fast tokenizer's Rust threads on
# (the only parallelism single-process tokenize gets; off makes prep run serially).
os.environ["TOKENIZERS_PARALLELISM"] = "true" if sys.platform in ("win32", "darwin") else "false"
# Make compiled cache modules importable by any subprocess. On spawn platforms
# (Windows/macOS) spawned dataset.map() workers re-import top-level modules, and
@ -62,7 +64,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 +72,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 +87,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 +110,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
@ -942,7 +933,7 @@ class UnslothTrainer:
use_gradient_checkpointing = "unsloth"
elif use_gradient_checkpointing in ("true", "1", "yes"):
use_gradient_checkpointing = True
elif use_gradient_checkpointing in ("false", "0", "no"):
elif use_gradient_checkpointing in ("false", "0", "no", "none", "off"):
use_gradient_checkpointing = False
else:
# Invalid value -> "unsloth"
@ -3466,8 +3457,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 +3473,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")

File diff suppressed because it is too large Load diff

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.
@ -2075,7 +2190,11 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
stop_queue: mp.Queue for stop commands from the parent.
config: Training config dict with all parameters.
"""
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# Off on Linux (forked datasets map() workers deadlock otherwise); on spawn
# platforms map() is in-process, so keep tokenizer threads on for faster prep.
os.environ["TOKENIZERS_PARALLELISM"] = (
"true" if sys.platform in ("win32", "darwin") else "false"
)
os.environ["PYTHONWARNINGS"] = "ignore" # before imports
# HTTP-fallback respawn: disable Xet before any huggingface_hub import (the
@ -2141,36 +2260,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 ──
@ -2474,6 +2583,15 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
_bnb_rocm_ver,
)
# Setting BNB_ROCM_VERSION makes bitsandbytes log a benign override
# notice on import; drop only that record so real errors and mismatch
# warnings still show.
if os.environ.get("BNB_ROCM_VERSION"):
import logging as _logging
_logging.getLogger("bitsandbytes.cextension").addFilter(
lambda _r: "environment variable detected" not in _r.getMessage()
)
# Parse HIP version for the kernel-fix gate below, falling back to
# the rocm version embedded in torch.__version__ when version.hip is
# unset (AMD SDK / Radeon wheels).
@ -2693,7 +2811,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,
@ -2904,11 +3023,24 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
),
xet_disabled = os.environ.get("HF_HUB_DISABLE_XET") == "1",
)
# Latest-sidecar models load 16-bit here too: bnb 4-bit feeds quantized
# expert weights into unvalidated paths (same flip as the chat worker).
_train_load_in_4bit = config["load_in_4bit"]
if _train_load_in_4bit:
from utils.transformers_version import latest_tier_active_for
if latest_tier_active_for(model_name, hf_token):
_train_load_in_4bit = False
logger.info(
"Latest-transformers sidecar active for %s - forcing a 16-bit "
"training load (4-bit is disabled for brand-new architectures)",
model_name,
)
try:
success = trainer.load_model(
model_name = model_name,
max_seq_length = config["max_seq_length"],
load_in_4bit = config["load_in_4bit"],
load_in_4bit = _train_load_in_4bit,
full_finetuning = not use_lora,
hf_token = hf_token,
is_dataset_image = config.get("is_dataset_image", False),

View file

@ -75,6 +75,10 @@ def spawn_worker(
env["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
env["HF_HUB_DISABLE_TELEMETRY"] = "1"
env["HF_HUB_DISABLE_XET"] = "0" if use_xet else "1"
# No token in Studio settings: fall back to the backend's own HF_TOKEN so
# private repos stay downloadable (needed while inkling repos are private).
if not hf_token:
hf_token = os.environ.get("HF_TOKEN") or None
env["HF_HUB_DISABLE_IMPLICIT_TOKEN"] = "0" if hf_token else "1"
# hf_transfer's parallel Range chunks can leave sparse partials even in
# "http" mode; disable so the worker's writer is always sequential.

View file

@ -49,6 +49,10 @@ _REPO_SIZE_NEG_TTL = 60.0
_MODEL_METADATA_TIMEOUT_SECONDS = 5.0
_repo_size_cache_lock = threading.Lock()
# Identity for a cached file with no HF blob (Windows without Developer Mode: hf
# moves the blob into snapshots/ and leaves blobs/ empty).
_LOCAL_SIZE_IDENTITY_PREFIX = "size:"
def get_repo_snapshot_metadata_cached(
repo_id: str, hf_token: Optional[str] = None
@ -135,23 +139,52 @@ def _cached_repo_file_name(file_obj) -> str:
return str(getattr(file_obj, "file_name", "")).replace("\\", "/")
def _is_real_cache_blob(blob: Optional[Path], repo_dir: Optional[Path]) -> bool:
"""True only for a real cache blob at ``<repo_dir>/blobs/<etag>``.
A no-symlink ``snapshots/`` file (name is the filename, not an etag) or a
repo's own ``blobs/`` subdir is not the cache blob store.
"""
if blob is None or repo_dir is None:
return False
try:
return blob.parent.resolve(strict = False) == (repo_dir / "blobs").resolve(strict = False)
except OSError:
return False
def _cached_blob_hash(blob_path, repo_path = None) -> Optional[str]:
"""The cache blob hash (etag) for a cached file, or None when there is no blob.
Only a real blob under the repo's ``blobs/`` dir has name == hash; a moved
no-symlink ``snapshots/`` file is "no blob", so the caller uses a size identity.
"""
path = Path(blob_path)
repo_dir = Path(repo_path) if repo_path is not None else None
return path.name if _is_real_cache_blob(path, repo_dir) else None
def local_size_identity(size: int) -> str:
"""Identity for a cached file whose blob hash is unknowable: its size.
Re-hashing multi-GB GGUFs on the inventory hot path is not viable, and a
``size:`` token never collides with a hex hash.
"""
return f"{_LOCAL_SIZE_IDENTITY_PREFIX}{int(size)}"
def _repo_gguf_blob_map(repo_info, *, include_companions: bool = False) -> dict[str, set[str]]:
"""Map each cached GGUF file's repo-relative name to the SET of its local
blob hashes across all cached revisions.
identities across all revisions.
HF names each local cache blob FILE by the file's etag (lfs.sha256 else
blob_id), so a local file's blob hash == ``Path(blob_path).name``. An updated
repo keeps BOTH the old and new revision snapshots until HF garbage-collects
them, so the same file resolves to several blobs; collecting them ALL (not
just the first one seen, since ``repo_info.revisions`` is a frozenset and
yields them in arbitrary order) lets the remote-vs-local diff treat the file
as current when the remote (``main``) blob is present in any cached revision.
Mirrors the ``cached_blob_ids`` membership test in routes/models.py.
By default this keeps the historical MAIN-GGUF-only behavior. GGUF update
checks opt into companions so a shared mmproj/MTP blob can be compared too.
An identity is the file's blob hash, or a size identity when the cache holds no
blob (Windows without Developer Mode). BOTH old and new revision blobs are kept
(a set), so the diff treats the file as current when the remote ``main`` blob is
in any cached revision. Main GGUF only by default; update checks opt into
companions to compare a shared mmproj/MTP blob too.
"""
blob_map: dict[str, set[str]] = {}
repo_path = getattr(repo_info, "repo_path", None)
for revision in repo_info.revisions:
for f in revision.files:
if include_companions:
@ -163,7 +196,13 @@ def _repo_gguf_blob_map(repo_info, *, include_companions: bool = False) -> dict[
if not blob_path:
continue
name = _cached_repo_file_name(f)
blob_map.setdefault(name, set()).add(Path(blob_path).name)
identity = _cached_blob_hash(blob_path, repo_path)
if identity is None:
size = int(getattr(f, "size_on_disk", 0) or 0)
if size <= 0:
continue
identity = local_size_identity(size)
blob_map.setdefault(name, set()).add(identity)
return blob_map

View file

@ -408,7 +408,13 @@ def reclaim_replaced_gguf_variant(
and extract_quant_label(name).lower() == variant_key,
)
for snap, blob, name in matches:
blob_hash = _blob_hash_from_path(blob) if blob is not None else None
# Prune only a file we can identify as a real, stale cache blob. A
# no-symlink snapshot file has no identifiable blob hash, so keep it.
blob_hash = (
_blob_hash_from_path(blob)
if cache_inventory._is_real_cache_blob(blob, repo_dir)
else None
)
if blob_hash is None or blob_hash in keep_main_hashes:
continue
stale_matches.append((snap, blob, name))

View file

@ -15,6 +15,7 @@ from loggers import get_logger
from hub.schemas.inventory import BrowseEntry, BrowseFoldersResponse
from hub.storage.scan_folders import (
contains_sensitive_path_component,
is_denied_system_path,
list_scan_folders,
)
from hub.utils.paths import (
@ -27,7 +28,10 @@ from hub.utils.paths import (
studio_root,
well_known_model_dirs,
)
from utils.paths.external_media import linux_run_media_mount_roots
from utils.paths.external_media import (
linux_run_media_mount_roots,
windows_drive_roots,
)
from hub.services.models.common import _safe_is_dir
from hub.services.models.local_inventory import _resolve_hf_cache_dir
@ -158,8 +162,14 @@ def _looks_like_model_dir(directory: Path) -> bool:
return False
def _build_browse_allowlist() -> list[Path]:
"""Root directories the browser may walk (also seeds the suggestion chips): HOME, resolved HF cache dirs, Studio outputs/exports/root, registered scan folders, and well-known local-LLM dirs. Each is added only if it resolves to a real directory so the sandbox has no dead boundary."""
def _build_browse_allowlist(
media_roots: Optional[list[Path]] = None, drive_roots: Optional[list[Path]] = None
) -> list[Path]:
"""Root directories the browser may walk (also seeds the suggestion chips): HOME, resolved HF cache dirs, Studio outputs/exports/root, registered scan folders, and well-known local-LLM dirs. Each is added only if it resolves to a real directory so the sandbox has no dead boundary.
*media_roots* / *drive_roots* let the caller pass already-probed
removable-media and Windows drive roots so they aren't scanned again (a
disconnected mapped drive can make each probe slow); probed here when ``None``."""
from hub.storage.scan_folders import list_scan_folders
candidates: list[Path] = []
@ -176,7 +186,13 @@ def _build_browse_allowlist() -> list[Path]:
candidates.append(resolved)
_add(Path.home())
for p in linux_run_media_mount_roots():
if media_roots is None:
media_roots = linux_run_media_mount_roots()
if drive_roots is None:
drive_roots = windows_drive_roots()
for p in media_roots:
_add(p)
for p in drive_roots:
_add(p)
_add(_resolve_hf_cache_dir())
try:
@ -218,7 +234,14 @@ def _build_browse_allowlist() -> list[Path]:
def _is_path_inside_allowlist(target: Path, allowed_roots: list[Path]) -> bool:
"""True if *target* equals or descends from any allowed root; uses ``os.path.realpath`` so symlinks cannot escape the sandbox."""
"""True if *target* equals or descends from any allowed root; uses ``os.path.realpath`` so symlinks cannot escape the sandbox.
A Windows drive root (``D:\\``) authorizes its descendants, but a bare POSIX
root (``/``) must NOT: a single ``/`` allowlist entry (e.g. a legacy scan
folder) would otherwise authorize every absolute path, reaching ``/var``,
``/root``, etc. the denylist does not cover. Mirrors the legacy browser so
both treat ``/`` identically.
"""
try:
target_real = os.path.normcase(os.path.realpath(str(target)))
except OSError:
@ -228,13 +251,25 @@ def _is_path_inside_allowlist(target: Path, allowed_roots: list[Path]) -> bool:
root_real = os.path.normcase(os.path.realpath(str(root)))
except OSError:
continue
if target_real == root_real:
return True
drive, tail = os.path.splitdrive(root_real)
if os.path.dirname(root_real) == root_real and not drive:
# Bare POSIX filesystem root ("/"): equality above is the only
# match; do not let it authorize arbitrary descendants.
continue
if drive.startswith(("\\\\", "//")) and not tail:
# Bare UNC share root (\\server\share): os.path.commonpath raises
# "can't mix absolute and relative" on it, so authorize its
# descendants with a boundary-safe prefix test (normcase applied).
if target_real.startswith(root_real.rstrip("\\/") + os.sep):
return True
continue
try:
if os.path.commonpath([target_real, root_real]) == root_real:
return True
except ValueError:
continue
if target_real == root_real:
return True
return False
@ -347,6 +382,11 @@ def _resolve_browse_target(path: Optional[str], allowed_roots: list[Path]) -> Pa
status_code = 403,
detail = "Credential or configuration directories are not browseable.",
)
if is_denied_system_path(str(resolved_child)):
raise HTTPException(
status_code = 403,
detail = "System directories are not browseable.",
)
current = resolved_child
if contains_sensitive_path_component(str(current)):
@ -354,6 +394,13 @@ def _resolve_browse_target(path: Optional[str], allowed_roots: list[Path]) -> Pa
status_code = 403,
detail = "Credential or configuration directories are not browseable.",
)
# Zero-component case: the requested path IS an allowlist root
# (e.g. a legacy-registered "/" or a Windows drive root).
if is_denied_system_path(str(current)):
raise HTTPException(
status_code = 403,
detail = "System directories are not browseable.",
)
if not current.is_dir():
raise HTTPException(
status_code = 400,
@ -382,9 +429,13 @@ def browse_folders_response(
"""
from hub.storage.scan_folders import list_scan_folders
# Probe removable-media and Windows drive roots once; the allowlist and
# chips reuse the result so a disconnected mapped drive isn't scanned twice.
media_roots = linux_run_media_mount_roots()
drive_roots = windows_drive_roots()
# Build the allowlist once -- the sandbox check and suggestion chips share
# it so chips are always navigable.
allowed_roots = _build_browse_allowlist()
allowed_roots = _build_browse_allowlist(media_roots, drive_roots)
try:
target = _resolve_browse_target(path, allowed_roots)
@ -440,6 +491,15 @@ def browse_folders_response(
# descending into them is refused and registration rejects them.
if contains_sensitive_path_component(name):
continue
# Same for denied system dirs (C:\Windows, /etc, ...): descent 403s,
# so don't render them as clickable rows. Resolve first so a
# symlink/junction into a denied dir is hidden too, not just a literal name.
try:
resolved_child = os.path.realpath(str(child))
except (OSError, ValueError):
resolved_child = str(child)
if is_denied_system_path(resolved_child):
continue
entries.append(
BrowseEntry(
name = name,
@ -487,13 +547,22 @@ def browse_folders_response(
return
if resolved in seen_sug:
return
# Drop a denied system dir (e.g. a stale scan-folder row) so it never
# becomes a chip that 403s on click. Drive roots stay: only their
# system subdirectories are denied, not the root itself.
if is_denied_system_path(resolved):
return
if _safe_is_dir(resolved):
seen_sug.add(resolved)
suggestions.append(resolved)
# Home first as the safe fallback.
_add_sug(Path.home())
for p in linux_run_media_mount_roots():
# Reuse the roots probed for the allowlist above (no second drive scan).
for p in media_roots:
_add_sug(p)
# Windows drive roots so the user can hop between C:, D:, E: ...
for p in drive_roots:
_add_sug(p)
# The HF cache root in use (honors HF_HOME / HF_HUB_CACHE), then the default.
try:

View file

@ -337,6 +337,22 @@ def _local_main_gguf_blobs_by_quant(repo_id: str) -> dict[str, dict[str, set[str
return result
def _size_identity_matches(local_set: set[str], remote_size: int) -> bool:
"""Whether a cached file with NO blob hash is current, judged by size.
A size token only lands in ``local_set`` for a file the cache has no blob for,
so it never loosens the hash comparison for a normal file. Tradeoff: an
equal-size requant is missed, versus the status quo where every no-blob GGUF
shows a phantom update that no re-download clears.
"""
size = int(remote_size or 0)
if size <= 0:
return False
from hub.services.models import cache_inventory
return cache_inventory.local_size_identity(size) in local_set
def _variant_update_available_from_requirement(
local_blobs: dict[str, set[str]], requirement: Optional[_GgufVariantRequirement], variant: str
) -> bool:
@ -355,8 +371,13 @@ def _variant_update_available_from_requirement(
if not remote_blob:
continue
local_set = local_by_posix.get(path)
if not local_set or remote_blob not in local_set:
if not local_set:
return True
if remote_blob in local_set:
continue
if _size_identity_matches(local_set, expected.size):
continue
return True
return False

View file

@ -12,6 +12,7 @@ summing stale blobs against the wrong total)."""
from __future__ import annotations
import asyncio
import threading
from pathlib import Path
from typing import Callable, Optional
@ -34,6 +35,28 @@ logger = get_logger(__name__)
# (repo_id, hf_token) -> (expected_total_bytes, expected_blob_hashes)
SnapshotMetadataResolver = Callable[[str, Optional[str]], "tuple[int, frozenset[str]]"]
# One progress log per 10% step per job, so an active download reports progress
# without emitting a line on every poll.
_progress_step_lock = threading.Lock()
_last_progress_step: dict[str, int] = {}
def _log_progress_step(job_key: str, repo_id: str, variant: Optional[str], progress: float) -> None:
step = int(progress * 10)
with _progress_step_lock:
last = _last_progress_step.get(job_key, -1)
if step == last:
return
_last_progress_step[job_key] = step
if step < last:
return # download restarted; resync without logging
logger.info(
"hub_download_progress",
repo_id = repo_id,
variant = variant or "",
percent = step * 10,
)
def _empty_progress(expected_bytes: int) -> dict:
return {
@ -215,6 +238,8 @@ def compute_snapshot_progress(
else 0
)
)
if force_active:
_log_progress_step(job_key, repo_id, variant, progress)
return {
"downloaded_bytes": display_downloaded_bytes,
"completed_bytes": display_completed_bytes,

View file

@ -16,7 +16,7 @@ from datetime import datetime, timezone
from storage.studio_db import get_connection
from hub.utils.paths import normalize_path
from utils.paths.external_media import is_linux_run_media_path
from utils.paths.external_media import is_linux_run_media_path, is_local_filesystem_root
from utils.paths.sensitive import (
contains_sensitive_path_component as _shared_contains_sensitive_path_component,
)
@ -52,6 +52,25 @@ def _denied_path_prefixes() -> list[str]:
return []
def is_denied_system_path(path: str) -> bool:
"""True if *path* is, or descends from, a denied system directory.
Mirrors the denylist add_scan_folder() enforces at registration so the
browser refuses /etc, /proc, C:\\Windows, etc. even when the allowlist holds
a broad root (a Windows drive root C:\\ or a legacy-registered / root). The
/run carve-out keeps Linux removable-media mounts browseable. Expects an
already-resolved (realpath) path so symlinks cannot escape into a denied subtree.
"""
is_win = platform.system() == "Windows"
check = os.path.normcase(path) if is_win else path
for prefix in _denied_path_prefixes():
if check == prefix or check.startswith(prefix + os.sep):
if prefix == "/run" and is_linux_run_media_path(check):
continue
return True
return False
def _contains_sensitive_path_component(path: str) -> bool:
return _shared_contains_sensitive_path_component(path)
@ -108,8 +127,9 @@ def add_scan_folder(path: str) -> dict:
raise ValueError("Path must be a directory, not a file")
if not os.access(normalized, os.R_OK | os.X_OK):
raise ValueError("Path is not readable")
if os.path.dirname(normalized) == normalized:
# Registering a filesystem root would expose denied system dirs via browse.
if is_local_filesystem_root(normalized):
# A local fs root ("/", "C:\\") would expose denied system dirs via browse;
# a UNC share root (\\server\share) has none under it and stays registerable.
raise ValueError("The filesystem root cannot be registered")
if _contains_sensitive_path_component(normalized):
raise ValueError("Credential or configuration directories are not allowed")

View file

@ -36,6 +36,20 @@ from hub.utils import (
from hub.workers import hf_download
@pytest.fixture(autouse = True)
def _denylist_inert(monkeypatch):
# The browse tests here exercise allowlist containment, symlink safety and
# the sensitive-name filter, not the system-directory denylist (which has
# its own suite in tests/test_browse_denylist.py). On macOS tmp_path
# resolves under /private/var, a denied prefix, so _resolve_browse_target
# would 403 the fixture dirs before that logic runs. Keep the denylist inert
# so these assertions hold on every platform. folder_browser binds
# is_denied_system_path at import, so patch it on that module, not on
# scan_folders. The "rejects" cases still 403 via the allowlist/sensitive
# checks, and the non-browse tests never call it.
monkeypatch.setattr(folder_browser, "is_denied_system_path", lambda _p: False)
def _repo(repo_id: str, files: list[SimpleNamespace], repo_path: Path):
return SimpleNamespace(
repo_id = repo_id,
@ -105,6 +119,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 +139,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 = []
@ -182,7 +242,8 @@ def test_browse_folders_hides_sensitive_dirs(monkeypatch, tmp_path):
home = tmp_path / "home"
(home / ".ssh").mkdir(parents = True)
(home / "models").mkdir()
monkeypatch.setattr(folder_browser, "_build_browse_allowlist", lambda: [home])
# Accept and ignore the optional (media_roots, drive_roots) args the caller now passes.
monkeypatch.setattr(folder_browser, "_build_browse_allowlist", lambda *_a, **_k: [home])
response = folder_browser.browse_folders_response(str(home), show_hidden = True)

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

@ -17,6 +17,15 @@ import structlog
from loggers.handlers import filter_sensitive_data
class _DropTorchDtypeDeprecation(logging.Filter):
"""Drop transformers' once-per-run "`torch_dtype` is deprecated" warning_once.
It is emitted via logging (not warnings), so a warnings filter cannot catch it."""
def filter(self, record: logging.LogRecord) -> bool:
msg = record.getMessage()
return not ("torch_dtype" in msg and "deprecated" in msg)
class LogConfig:
"""Structured logging configuration for the application."""
@ -72,4 +81,13 @@ class LogConfig:
cache_logger_on_first_use = True,
)
# Drop transformers' cosmetic "`torch_dtype` is deprecated" warning_once (see filter).
_dtype_filter = _DropTorchDtypeDeprecation()
for _name in (
"transformers.configuration_utils",
"transformers.modeling_utils",
"transformers.pipelines.base",
):
logging.getLogger(_name).addFilter(_dtype_filter)
return structlog.get_logger(service_name)

View file

@ -28,19 +28,26 @@ def _env_int(name: str, default: int) -> int:
return default
# Drop duplicate successful-GET access logs repeated within the window: the SPA
# fans one cache invalidation into many identical list fetches; only the first
# informs. Loading polls, mutations, and errors are unaffected. 0 = log all.
# Collapse identical GET/2xx logs within the window (the SPA fans one invalidation
# into many list fetches). Mutations and errors always log. 0 = off.
_ACCESS_LOG_DEDUP_MS = _env_int("UNSLOTH_STUDIO_ACCESS_LOG_DEDUP_MS", 300)
# Pure-liveness/UI polls whose access line carries no signal beyond "client still
# polling" (state changes are logged by their own modules). Collapsed to a longer
# heartbeat instead of one line per poll; first hit and any error still log. 0 = off.
# Liveness/UI polls whose line means only "still polling"; collapse to a longer
# heartbeat. First hit and errors still log. 0 = off.
_QUIET_POLL_DEDUP_MS = _env_int("UNSLOTH_STUDIO_ACCESS_LOG_POLL_DEDUP_MS", 10000)
_QUIET_POLL_PATHS = {
"/api/health",
"/api/auth/status",
"/api/inference/status",
"/api/inference/monitor",
# List polls the tabs refetch on a timer and on every tab switch.
"/api/train/runs",
"/api/models/checkpoints",
"/api/models/local",
"/api/rag/knowledge-bases",
# Legacy download polls emit no progress events (unlike /api/hub/*), so heartbeat them.
"/api/models/download-progress",
"/api/models/gguf-download-progress",
"/api/datasets/download-progress",
}
_DEDUP_MAP_MAX = 4096
_NATIVE_PATH_LEASE_RE = re.compile(
@ -62,6 +69,46 @@ _EXCLUDED_SUFFIXES = (
".woff2",
".ttf",
)
# GET polls whose 2xx line carries no signal (their progress/phase events and the UI
# do), so drop it entirely; non-2xx still logs. Only /api/hub download polls emit
# events; the legacy /api/models and /api/datasets ones heartbeat via _QUIET_POLL_PATHS.
_QUIET_SUCCESS_PATHS = {
"/api/inference/load-progress",
"/api/llama/update-status",
"/api/export/logs",
"/api/export/status",
"/api/hub/download-status",
"/api/hub/download-progress",
"/api/hub/gguf-download-progress",
"/api/hub/active-downloads",
"/api/hub/transport-status",
"/api/hub/datasets/download-status",
"/api/hub/datasets/download-progress",
"/api/hub/datasets/active-downloads",
"/api/hub/datasets/transport-status",
}
# The token-refresh route. Its first 2xx means the client has obtained a valid
# session, so from then on chat 401s are real failures and must stay visible.
_AUTH_REFRESH_PATH = "/api/auth/refresh"
# High-frequency chat list polls; their 2xx is covered by generation/tool-call/stats
# events. Exact paths only, so detail/message reads (/threads/{id}, .../messages,
# /projects/{id}) keep their logs. The pre-auth 401 race also fires on these polls.
_CHAT_LIST_PATHS = {
"/api/chat/threads",
"/api/chat/projects",
}
def _is_quiet_success(method: str, path: str, status_code: int, pre_auth: bool) -> bool:
"""GET-only. Suppress a 2xx poll line that carries no signal, plus a chat list
poll's transient pre-auth 401 (only in the bootstrap window before the first
successful token refresh). Mutations, real (post-refresh) auth failures, and
all other errors always log."""
if method != "GET":
return False
if 200 <= status_code < 300:
return path in _QUIET_SUCCESS_PATHS or path in _CHAT_LIST_PATHS
return pre_auth and status_code == 401 and path in _CHAT_LIST_PATHS
class LoggingMiddleware:
@ -71,14 +118,16 @@ class LoggingMiddleware:
self.app = app
# (method, path, query, status_code) -> monotonic ts of the last EMITTED log.
self._last_log: dict[tuple[str, str, bytes, int], float] = {}
# Flips True after the first successful /api/auth/refresh; before that, chat
# list-poll 401s are the transient bootstrap race and are suppressed.
self._auth_refreshed = False
def _is_redundant_repeat(
self, method: str, path: str, query: bytes, status_code: int, now: float
) -> bool:
"""True if an identical GET/2xx log fired < window ago. The query string
is part of the identity, so distinct query-driven GETs are not collapsed.
Mutations and non-2xx are never deduped. Quiet-poll paths use a longer
heartbeat window. Stamps only on emit, so steady polls still log."""
"""True if an identical GET/2xx log fired < window ago (query string is part
of the identity). Non-GET/non-2xx never dedup; quiet-poll paths use the longer
heartbeat. Stamps only on emit, so steady polls still log."""
if method != "GET" or not (200 <= status_code < 300):
return False
window_ms = _QUIET_POLL_DEDUP_MS if path in _QUIET_POLL_PATHS else _ACCESS_LOG_DEDUP_MS
@ -129,8 +178,16 @@ class LoggingMiddleware:
raise
else:
end_time = time.perf_counter()
if not excluded and not self._is_redundant_repeat(
scope["method"], path, scope.get("query_string", b""), status_code, end_time
if 200 <= status_code < 300 and path == _AUTH_REFRESH_PATH:
self._auth_refreshed = True
if (
not excluded
and not _is_quiet_success(
scope["method"], path, status_code, not self._auth_refreshed
)
and not self._is_redundant_repeat(
scope["method"], path, scope.get("query_string", b""), status_code, end_time
)
):
logger.info(
"request_completed",

View file

@ -159,6 +159,14 @@ if sys.platform == "win32":
_bnb_rocm_ver_final,
)
# Setting BNB_ROCM_VERSION makes bitsandbytes log a benign override notice on
# import; drop only that record so real errors and mismatch warnings show.
if os.environ.get("BNB_ROCM_VERSION"):
import logging as _logging
_logging.getLogger("bitsandbytes.cextension").addFilter(
lambda _r: "environment variable detected" not in _r.getMessage()
)
# ── WSL AMD Strix Halo (gfx1151): enable ROCDXG before any torch import ──────
# In WSL the AMD GPU is reached via the ROCDXG bridge (librocdxg.so over
# /dev/dxg), which HSA loads only when HSA_ENABLE_DXG_DETECTION=1 is set BEFORE
@ -226,6 +234,7 @@ if _STUDIO_ROOT_RESOLVED != _LEGACY_STUDIO_ROOT:
os.environ.setdefault("UNSLOTH_IS_PRESENT", "1")
import hashlib
import ipaddress
import mimetypes
import re as _re
import shutil
@ -551,8 +560,12 @@ async def lifespan(app: FastAPI):
(_time.perf_counter() - _lifespan_started) * 1000,
)
# run_server's pre-bind gate sets suppress_bootstrap_injection when a public
# URL is about to serve with the default credential active: never (re)capture
# the bootstrap password into app.state, or the HTML would hand it out.
_suppress_bootstrap = getattr(app.state, "suppress_bootstrap_injection", False)
if storage.ensure_default_admin():
bootstrap_pw = storage.get_bootstrap_password()
bootstrap_pw = None if _suppress_bootstrap else storage.get_bootstrap_password()
app.state.bootstrap_password = bootstrap_pw
bootstrap_path = storage.DB_PATH.parent / ".bootstrap_password"
@ -563,7 +576,9 @@ async def lifespan(app: FastAPI):
print(" Open the Studio UI to sign in and change it.")
print("=" * 60 + "\n")
else:
app.state.bootstrap_password = storage.get_bootstrap_password()
app.state.bootstrap_password = (
None if _suppress_bootstrap else storage.get_bootstrap_password()
)
_lifespan_log.info(
"lifespan startup completed in %.1fms",
@ -1355,6 +1370,61 @@ def _canonical_origin(scheme: str, netloc: str) -> Optional[tuple[str, str, int]
return (scheme, host, port)
def _is_loopback_ip(host: Optional[str]) -> bool:
"""Return whether ``host`` is a loopback IP, including IPv4-mapped IPv6."""
if not host or "%" in host: # a scope id (::1%eth0) is never a plain loopback
return False
try:
ip = ipaddress.ip_address(host)
except (TypeError, ValueError):
return False
mapped = getattr(ip, "ipv4_mapped", None)
return ip.is_loopback or (mapped is not None and mapped.is_loopback)
# A loopback peer carrying any of these is a proxy/tunnel relaying a remote
# client, so the peer is the proxy, not the caller: cloudflared sets
# cf-connecting-ip, reverse proxies set the rest (uvicorn only consumes
# x-forwarded-for, so the others survive to here).
_PROXIED_CLIENT_HEADERS = (
"cf-connecting-ip",
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-real-ip",
)
def _host_header_is_loopback(host_header: Optional[str]) -> bool:
"""Loopback/localhost check on the raw Host header.
Reads the header directly so a malformed or absent Host cannot fall back to
``request.url.hostname``'s (loopback) ASGI server address.
"""
if not host_header:
return False
host = host_header.strip()
if host.startswith("["): # [IPv6] or [IPv6]:port
end = host.find("]")
if end == -1 or (host[end + 1 :] and not host[end + 1 :].startswith(":")):
return False # unclosed bracket or junk after ] (e.g. [::1]evil)
host = host[1:end]
elif host.count(":") == 1: # host:port
host = host.split(":", 1)[0]
host = host.lower().rstrip(".")
return host == "localhost" or _is_loopback_ip(host)
def _is_local_bootstrap_request(request: Request) -> bool:
"""Allow bootstrap injection only through a direct loopback authority."""
client = request.client
if client is None or not _is_loopback_ip(client.host):
return False
if any(request.headers.get(h) is not None for h in _PROXIED_CLIENT_HEADERS):
return False
return _host_header_is_loopback(request.headers.get("host"))
def _is_same_origin_request(request: Request) -> bool:
"""True when Origin is missing or matches request's scheme://host:port.
@ -1390,6 +1460,17 @@ def _is_same_origin_request(request: Request) -> bool:
return origin_canon == self_canon
def _should_inject_bootstrap(request: Request) -> bool:
"""Whether to embed the seeded bootstrap password in index.html."""
if not _is_same_origin_request(request):
return False
if _IS_COLAB:
# Single-user notebook proxy: allow autofill, but never a public
# shareable tunnel (a Colab Cloudflare link sets cf-connecting-ip).
return request.headers.get("cf-connecting-ip") is None
return _is_local_bootstrap_request(request)
def setup_frontend(app: FastAPI, build_path: Path):
"""Mount frontend static files (optional)"""
if not build_path.exists():
@ -1402,8 +1483,10 @@ def setup_frontend(app: FastAPI, build_path: Path):
def _build_index_response(request: Request) -> Response:
content = (build_path / "index.html").read_bytes()
content = _strip_crossorigin(content)
# Bootstrap pw is same-origin only; Vary: Origin keeps caches honest.
if _is_same_origin_request(request):
# Bootstrap pw goes only to a same-origin, direct-loopback client (or
# Colab's single-user notebook proxy): a wildcard bind must not serve it
# in-page to a LAN or proxied peer. Vary: Origin keeps caches honest.
if _should_inject_bootstrap(request):
content, nonce = _inject_bootstrap(content, app)
else:
nonce = None

View file

@ -7,6 +7,8 @@ from typing import Optional
from pydantic import BaseModel, Field
from auth.storage import MIN_PASSWORD_LENGTH
class AuthLoginRequest(BaseModel):
"""Login payload: username/password to obtain a JWT."""
@ -45,10 +47,14 @@ class ChangePasswordRequest(BaseModel):
"""Change the current user's password, typically on first login."""
current_password: str = Field(
..., min_length = 8, description = "Existing password for the authenticated user"
...,
min_length = MIN_PASSWORD_LENGTH,
description = "Existing password for the authenticated user",
)
new_password: str = Field(
..., min_length = 8, description = "Replacement password (minimum 8 characters)"
...,
min_length = MIN_PASSWORD_LENGTH,
description = f"Replacement password (minimum {MIN_PASSWORD_LENGTH} characters)",
)

View file

@ -140,6 +140,27 @@ class ValidateModelRequest(BaseModel):
)
class TransformersUpgradeInfo(BaseModel):
"""A model architecture no installed transformers ships, but a newer release does."""
model_type: str = Field(
..., description = "config.json model_type unknown to every installed transformers"
)
pypi_version: Optional[str] = Field(
None, description = "Latest transformers release on PyPI at check time"
)
supported_in_pypi: bool = Field(
False,
description = "True if the latest PyPI release ships this model_type; Studio can "
"install it into a persistent sidecar after user consent.",
)
supported_in_main: bool = Field(
False,
description = "True if transformers GitHub main ships this model_type (dev-only; "
"not installable through Studio yet).",
)
class ValidateModelResponse(BaseModel):
"""Result of model validation.
@ -167,6 +188,48 @@ class ValidateModelResponse(BaseModel):
description = "Native training context length, read from the GGUF header when the file "
"is already downloaded locally; None for non-GGUF, gated, or not-yet-downloaded models.",
)
# Additive fields; the consuming consent dialog ships in a follow-up frontend PR.
requires_transformers_upgrade: bool = Field(
False,
description = "True when the model's architecture is unknown to every installed "
"transformers but a newer transformers ships it; the UI should offer the "
"install-latest-transformers consent dialog (or the dev-only notice).",
)
transformers_upgrade: Optional[TransformersUpgradeInfo] = Field(
None,
description = "Details for the transformers-upgrade dialog; set only when "
"requires_transformers_upgrade is true.",
)
class InstallLatestTransformersRequest(BaseModel):
"""Consented request to install the latest transformers release into a sidecar."""
version: str = Field(
...,
min_length = 1,
max_length = 64,
description = "Exact transformers version to install; must match the current "
"latest PyPI release reported by /validate.",
)
class InstallLatestTransformersResponse(BaseModel):
"""Result of the consented latest-transformers sidecar install."""
success: bool = Field(..., description = "Whether the sidecar was provisioned")
version: str = Field(..., description = "The requested transformers version")
message: str = Field(..., description = "Human-readable result")
model_unloaded: bool = Field(
False,
description = "Whether the active chat model was unloaded before the swap "
"(reported even on failure, so the client can restore its state)",
)
latest_version: Optional[str] = Field(
None,
description = "On a version-mismatch failure: the release that superseded "
"the requested one, so the client can retry with it",
)
class GenerateRequest(BaseModel):
@ -632,6 +695,23 @@ class ThinkingConfig(BaseModel):
type: Literal["disabled", "enabled"] = "disabled"
# Recognized permission_mode values. The field accepts a plain string rather than
# a Literal so an unrecognized value from a newer UI/client degrades to the
# safest gate ("ask") instead of a 422; the tool loops apply the same unknown ->
# ask fallback, so normalizing here keeps that forward-compat path reachable at
# the API boundary. None stays unset ("behaves as 'ask'" without self-enabling
# the confirm gate).
_KNOWN_PERMISSION_MODES = ("ask", "auto", "off", "full")
def _normalize_permission_mode(value: Any) -> Any:
if value is None:
return None
if value not in _KNOWN_PERMISSION_MODES:
return "ask"
return value
class ChatCompletionRequest(BaseModel):
"""OpenAI-compatible chat completion request.
@ -777,6 +857,19 @@ class ChatCompletionRequest(BaseModel):
False,
description = "[x-unsloth] Bypass Permissions: when true, skip the tool-call confirmation gate AND disable the python/terminal execution sandbox (safety checks, command blocklist, resource limits). Secret env vars are still stripped. Takes precedence over confirm_tool_calls.",
)
permission_mode: Optional[str] = Field(
None,
description = (
"[x-unsloth] Permission level for local tool calls. 'ask' pauses every "
"call for approval; 'ask'/'auto' enable the confirmation gate on their "
"own (needs a streaming request to deliver prompts). 'auto' ('Approve for "
"me') only pauses calls detected as potentially unsafe (state-mutating "
"terminal/python/MCP calls); read-only calls run immediately, and the "
"sandbox stays on. 'full' is equivalent to bypass_permissions=true (no "
"confirmation, no sandbox). Unset behaves as 'ask'. An unrecognized value "
"(e.g. from a newer client) is treated as 'ask'."
),
)
auto_heal_tool_calls: Optional[bool] = Field(
True,
description = "[x-unsloth] Auto-detect and fix malformed tool calls from model output.",
@ -815,6 +908,10 @@ class ChatCompletionRequest(BaseModel):
None,
description = "[x-unsloth] Session/thread ID for scoping tool execution sandbox.",
)
thread_id: Optional[str] = Field(
None,
description = "[x-unsloth] Conversation ID for scoping stateful tool sessions (e.g. stdio MCP); stays per-thread where session_id may be shared project-wide.",
)
rag_scope: Optional[dict] = Field(
None,
description = (
@ -1036,6 +1133,52 @@ class ChatCompletionRequest(BaseModel):
self.enable_thinking = self.thinking.type == "enabled"
return self
@field_validator("permission_mode", mode = "before")
@classmethod
def _coerce_permission_mode(cls, value: Any) -> Any:
# Accept any string so an unknown mode degrades to 'ask' instead of a
# 422; mirrors the tool loops' unknown -> ask fallback.
return _normalize_permission_mode(value)
@model_validator(mode = "after")
def _fold_full_permission_into_bypass(self) -> "ChatCompletionRequest":
"""permission_mode='full' is the documented equivalent of
bypass_permissions=true, so fold it in before any route guard reads
the flag (else a full request would trip the confirm-gate rejections)."""
if self.permission_mode == "full":
self.bypass_permissions = True
elif self.bypass_permissions:
# Legacy bypass callers map onto Full access (mirrors the tool loop).
self.permission_mode = "full"
elif self.permission_mode == "off":
# "Off" never prompts, so route guards must see confirm disabled.
self.confirm_tool_calls = False
elif (
self.permission_mode == "ask"
and self.confirm_tool_calls is None
and not (self.provider_id or self.provider_type)
and (self.enable_tools is True or bool(self.mcp_enabled))
):
# "Ask" gates every call, so a direct API caller that omits the legacy
# confirm flag must still hit the confirmation gate for Studio's own
# tool loop. An explicit confirm_tool_calls=False wins over the mode
# (mirrors _permission_mode_confirm and the Anthropic pre-switch guard),
# so only self-enable when the flag is unset. Only self-enable when that
# loop is actually requested
# (enable_tools / mcp_enabled) -- the router enters the loop on those
# signals, not on enabled_tools alone (which merely filters which tools
# run). A plain client-tool passthrough (client-supplied `tools` that
# Studio does not execute) must route verbatim, and external-provider
# routing rejects confirm_tool_calls with tools, so skip the fold there.
#
# "auto" is deliberately NOT folded: it only prompts for a call the
# classifier flags, so leaving confirm_tool_calls unset lets the route's
# _confirm_gate_needs_stream apply the safe-only exception (a safe-only
# auto selection needs no stream) instead of an explicit-confirm forcing
# stream=true. The mode still drives the loop's per-call gate.
self.confirm_tool_calls = True
return self
class ToolConfirmRequest(BaseModel):
session_id: Optional[str] = None
@ -1533,12 +1676,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,
]
@ -1583,6 +1755,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.
@ -1619,11 +1825,19 @@ class AnthropicMessagesRequest(BaseModel):
enable_tools: Optional[bool] = None
enabled_tools: Optional[list[str]] = None
session_id: Optional[str] = None
thread_id: Optional[str] = Field(
None,
description = "[x-unsloth] Conversation ID for scoping stateful tool sessions (e.g. stdio MCP); stays per-thread where session_id may be shared project-wide.",
)
cancel_id: Optional[str] = None
bypass_permissions: Optional[bool] = Field(
False,
description = "[x-unsloth] Bypass Permissions: when true, disable the python/terminal execution sandbox (safety checks, command blocklist, resource limits) for server-side tool calls. Secret env vars are still stripped. Declared explicitly (not relied on via extra='allow') so omitted requests default to False instead of raising AttributeError.",
)
permission_mode: Optional[str] = Field(
None,
description = "[x-unsloth] Permission level for local tool calls: 'ask' pauses every call, 'auto' only pauses calls detected as potentially unsafe, 'off' never pauses (sandbox stays on), 'full' equals bypass_permissions=true. Unset behaves as 'ask'; an unrecognized value (e.g. from a newer client) is treated as 'ask'. Declared explicitly so omitted requests default to None instead of raising AttributeError.",
)
auto_heal_tool_calls: Optional[bool] = Field(
True,
description = "[x-unsloth] Auto-detect and fix malformed tool calls from model output (mirrors the Chat Completions field; applies to the client-tool passthrough).",
@ -1665,6 +1879,27 @@ class AnthropicMessagesRequest(BaseModel):
normalized["system"] = _merge_anthropic_system(normalized.get("system"), system_additions)
return normalized
@field_validator("permission_mode", mode = "before")
@classmethod
def _coerce_permission_mode(cls, value: Any) -> Any:
# Accept any string so an unknown mode degrades to 'ask' instead of a
# 422; mirrors the tool loops' unknown -> ask fallback.
return _normalize_permission_mode(value)
@model_validator(mode = "after")
def _fold_full_permission_into_bypass(self) -> "AnthropicMessagesRequest":
"""permission_mode='full' equals bypass_permissions=true (mirrors the
Chat Completions request)."""
if self.permission_mode == "full":
self.bypass_permissions = True
elif self.bypass_permissions:
# Legacy bypass callers map onto Full access (mirrors the tool loop).
self.permission_mode = "full"
elif self.permission_mode == "off":
# "Off" never prompts, so route guards must see confirm disabled.
self.confirm_tool_calls = False
return self
# ── Response models ────────────────────────────────────────────

View file

@ -500,8 +500,9 @@ async def change_password(
detail = "New password must be different from the current password",
)
storage.update_password(current_subject, payload.new_password)
storage.revoke_user_refresh_tokens(current_subject)
# Single transaction: a separate refresh-token purge could fail after the
# password commit, leaving pre-change tokens able to mint access tokens.
storage.update_password(current_subject, payload.new_password, revoke_refresh_tokens = True)
try:
request.app.state.bootstrap_password = None
except AttributeError:

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:

View file

@ -51,7 +51,17 @@ def _ensure_export_supported() -> None:
Keeps the backend authoritative even if a client bypasses the UI gate. Read-only endpoints
(scan/status/logs) are intentionally NOT gated so the Export page can still render the reason.
Also refuses (409) while a latest-transformers install is swapping .venv_t5_latest: an
export worker spawned mid-swap could activate a half-replaced sidecar.
"""
from utils.transformers_latest import is_install_in_progress
if is_install_in_progress():
raise HTTPException(
status_code = 409,
detail = "A transformers installation is in progress. Retry when it completes.",
)
from utils.hardware import export_capability
cap = export_capability()
@ -97,6 +107,11 @@ async def load_checkpoint(
except HTTPException:
raise
except Exception as e:
from utils.transformers_version import SidecarSwapInProgress
if isinstance(e, SidecarSwapInProgress):
# Expected loss of the race against a sidecar install: retryable 409.
raise HTTPException(status_code = 409, detail = str(e))
logger.error(f"Error loading checkpoint: {e}", exc_info = True)
raise HTTPException(
status_code = 500,
@ -308,6 +323,11 @@ async def export_merged_model(
except HTTPException:
raise
except Exception as e:
from utils.transformers_version import SidecarSwapInProgress
if isinstance(e, SidecarSwapInProgress):
# Expected loss of the race against a sidecar install: retryable 409.
raise HTTPException(status_code = 409, detail = str(e))
logger.error(f"Error exporting merged model: {e}", exc_info = True)
raise HTTPException(
status_code = 500,
@ -347,6 +367,11 @@ async def export_base_model(
except HTTPException:
raise
except Exception as e:
from utils.transformers_version import SidecarSwapInProgress
if isinstance(e, SidecarSwapInProgress):
# Expected loss of the race against a sidecar install: retryable 409.
raise HTTPException(status_code = 409, detail = str(e))
logger.error(f"Error exporting base model: {e}", exc_info = True)
raise HTTPException(
status_code = 500,
@ -388,6 +413,11 @@ async def export_gguf(
except HTTPException:
raise
except Exception as e:
from utils.transformers_version import SidecarSwapInProgress
if isinstance(e, SidecarSwapInProgress):
# Expected loss of the race against a sidecar install: retryable 409.
raise HTTPException(status_code = 409, detail = str(e))
logger.error(f"Error exporting GGUF model: {e}", exc_info = True)
raise HTTPException(
status_code = 500,
@ -428,6 +458,11 @@ async def export_lora_adapter(
except HTTPException:
raise
except Exception as e:
from utils.transformers_version import SidecarSwapInProgress
if isinstance(e, SidecarSwapInProgress):
# Expected loss of the race against a sidecar install: retryable 409.
raise HTTPException(status_code = 409, detail = str(e))
logger.error(f"Error exporting LoRA adapter: {e}", exc_info = True)
raise HTTPException(
status_code = 500,

File diff suppressed because it is too large Load diff

View file

@ -14,14 +14,17 @@ never blocks on a missing marker / offline GitHub.
from __future__ import annotations
import asyncio
import threading
from typing import Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from auth.authentication import get_current_subject
from loggers import get_logger
from utils.llama_cpp_update import get_update_status, start_update
logger = get_logger(__name__)
router = APIRouter()
@ -69,6 +72,27 @@ class LlamaUpdateActionResponse(BaseModel):
job: LlamaUpdateJob = Field(default_factory = LlamaUpdateJob)
_llama_update_lock = threading.Lock()
_last_llama_update_step = -1
def _log_llama_update_progress(job: LlamaUpdateJob) -> None:
"""One llama_update_progress line per 10% step so a prebuilt update reports
progress without a line per poll. Resyncs when a new update starts."""
global _last_llama_update_step
if job.state != "running" or job.progress is None:
return
step = int(max(0.0, min(float(job.progress), 1.0)) * 10)
with _llama_update_lock:
prev = _last_llama_update_step
if step == prev:
return
_last_llama_update_step = step
if step < prev:
return # new update; resync without logging
logger.info("llama_update_progress", to_tag = job.to_tag or "", percent = step * 10)
@router.get("/update-status", response_model = LlamaUpdateStatusResponse)
async def llama_update_status(
force_refresh: bool = Query(
@ -78,7 +102,9 @@ async def llama_update_status(
) -> LlamaUpdateStatusResponse:
# Off the event loop: detection may probe the host and read GitHub.
status = await asyncio.to_thread(get_update_status, force_refresh = force_refresh)
return LlamaUpdateStatusResponse(**status)
resp = LlamaUpdateStatusResponse(**status)
_log_llama_update_progress(resp.job)
return resp
@router.post("/update", response_model = LlamaUpdateActionResponse)

View file

@ -1,6 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import asyncio
import json
import uuid
from urllib.parse import urlparse
@ -13,6 +14,7 @@ from core.inference.mcp_client import (
TOOL_CACHE_INVALIDATING_FIELDS,
cache_tools,
clear_oauth_tokens_async,
close_stdio_sessions,
invalidate_tool_cache,
is_stdio,
list_tools_async,
@ -206,11 +208,15 @@ async def update_mcp_server(
):
await clear_oauth_tokens_async(old["url"])
mcp_servers_db.update_server(server_id, changes)
# A new endpoint/auth makes cached tools wrong and disabling makes them
# unreachable, so drop them and let the next send re-probe; a rename
# leaves them valid.
if changes.keys() & TOOL_CACHE_INVALIDATING_FIELDS:
# A new endpoint/auth makes cached tools wrong and disabling makes them unreachable, so drop
# them and let the next send re-probe; a rename leaves them valid. Live stdio sessions for the
# old endpoint close too. Gate on a real value change, not mere presence: the edit dialog
# resends url/headers/oauth unchanged on a rename, which must not drop the session.
if any(changes[k] != old.get(k) for k in changes.keys() & TOOL_CACHE_INVALIDATING_FIELDS):
invalidate_tool_cache(server_id)
# Narrow to this row's env: another server row sharing the command but
# with a different env keeps its live sessions.
await asyncio.to_thread(close_stdio_sessions, old["url"], parse_server_headers(old))
return _row_to_response(mcp_servers_db.get_server(server_id))
@ -223,6 +229,7 @@ async def delete_mcp_server(server_id: str, current_subject: str = Depends(get_c
await clear_oauth_tokens_async(old["url"])
mcp_servers_db.delete_server(server_id)
invalidate_tool_cache(server_id)
await asyncio.to_thread(close_stdio_sessions, old["url"], parse_server_headers(old))
@router.post("/{server_id}/refresh", response_model = McpServerProbeResult)

View file

@ -1188,7 +1188,9 @@ def _looks_like_model_dir(directory: Path) -> bool:
return False
def _build_browse_allowlist() -> list[Path]:
def _build_browse_allowlist(
media_roots: Optional[list[Path]] = None, drive_roots: Optional[list[Path]] = None
) -> list[Path]:
"""Return the root directories the folder browser may walk.
The same list seeds the sidebar suggestion chips, so chip targets are
@ -1196,13 +1198,20 @@ def _build_browse_allowlist() -> list[Path]:
outputs/exports/studio root, registered scan folders, and well-known
local-LLM dirs (LM Studio, Ollama, ``~/models``); each added only if
it resolves to a real directory.
*media_roots* / *drive_roots* let the caller pass already-probed
removable-media and Windows drive roots so they aren't scanned again (a
disconnected mapped drive can make each probe slow); probed here when ``None``.
"""
from utils.paths import (
hf_default_cache_dir,
legacy_hf_cache_dir,
well_known_model_dirs,
)
from utils.paths.external_media import linux_run_media_mount_roots
from utils.paths.external_media import (
linux_run_media_mount_roots,
windows_drive_roots,
)
from storage.studio_db import list_scan_folders
candidates: list[Path] = []
@ -1218,7 +1227,13 @@ def _build_browse_allowlist() -> list[Path]:
candidates.append(resolved)
_add(Path.home())
for p in linux_run_media_mount_roots():
if media_roots is None:
media_roots = linux_run_media_mount_roots()
if drive_roots is None:
drive_roots = windows_drive_roots()
for p in media_roots:
_add(p)
for p in drive_roots:
_add(p)
_add(_resolve_hf_cache_dir())
try:
@ -1269,19 +1284,43 @@ def _build_browse_allowlist() -> list[Path]:
def _is_path_inside_allowlist(target: Path, allowed_roots: list[Path]) -> bool:
"""True if *target* equals or descends from any allowed root.
Uses ``os.path.realpath`` so symlinks can't escape the sandbox.
Uses ``os.path.realpath`` (symlinks can't escape the sandbox) and
``os.path.commonpath`` for a component-wise containment test, so a string
prefix like ``/home/u`` never matches a sibling ``/home/user2`` while a
drive root ``D:\\`` still contains ``D:\\models``. A Windows drive root
authorizes its descendants, but a bare POSIX root ``/`` must NOT, else one
``/`` allowlist entry would authorize every absolute path. ``normcase`` keeps
the drive-letter comparison case-insensitive, matching the hub browser.
"""
try:
target_real = os.path.realpath(str(target))
target_real = os.path.normcase(os.path.realpath(str(target)))
except OSError:
return False
for root in allowed_roots:
try:
root_real = os.path.realpath(str(root))
root_real = os.path.normcase(os.path.realpath(str(root)))
except OSError:
continue
if target_real == root_real or target_real.startswith(root_real + os.sep):
if target_real == root_real:
return True
drive, tail = os.path.splitdrive(root_real)
if os.path.dirname(root_real) == root_real and not drive:
# Bare POSIX filesystem root ("/"): equality above is the only
# match; do not let it authorize arbitrary descendants.
continue
if drive.startswith(("\\\\", "//")) and not tail:
# Bare UNC share root (\\server\share): os.path.commonpath raises
# "can't mix absolute and relative" on it, so authorize its
# descendants with a boundary-safe prefix test (normcase applied).
if target_real.startswith(root_real.rstrip("\\/") + os.sep):
return True
continue
try:
if os.path.commonpath([target_real, root_real]) == root_real:
return True
except ValueError:
# Different drives / mixed absolute-relative: not contained.
continue
return False
@ -1339,7 +1378,10 @@ def _match_browse_child(current: Path, name: str) -> Optional[Path]:
def _resolve_browse_target(path: Optional[str], allowed_roots: list[Path]) -> Path:
"""Resolve a requested browse path by walking from trusted allowlist roots."""
from storage.studio_db import contains_sensitive_path_component
from storage.studio_db import (
contains_sensitive_path_component,
is_denied_system_path,
)
requested_path = _normalize_browse_request_path(path)
resolved_roots: list[Path] = []
@ -1396,6 +1438,11 @@ def _resolve_browse_target(path: Optional[str], allowed_roots: list[Path]) -> Pa
status_code = 403,
detail = "Credential or configuration directories are not browseable.",
)
if is_denied_system_path(str(resolved_child)):
raise HTTPException(
status_code = 403,
detail = "System directories are not browseable.",
)
current = resolved_child
if contains_sensitive_path_component(str(current)):
@ -1403,6 +1450,13 @@ def _resolve_browse_target(path: Optional[str], allowed_roots: list[Path]) -> Pa
status_code = 403,
detail = "Credential or configuration directories are not browseable.",
)
# Zero-component case: the requested path IS an allowlist root
# (e.g. a legacy-registered "/" or a Windows drive root).
if is_denied_system_path(str(current)):
raise HTTPException(
status_code = 403,
detail = "System directories are not browseable.",
)
if not current.is_dir():
raise HTTPException(
status_code = 400,
@ -1420,8 +1474,12 @@ def _resolve_browse_target(path: Optional[str], allowed_roots: list[Path]) -> Pa
)
# Sync (def, not async) so FastAPI runs the blocking filesystem I/O (drive
# probes, iterdir, realpath) in the threadpool: a disconnected mapped drive can
# make the probe wait out its timeout, which on the event loop would stall every
# other request. Matches the hub browse endpoint.
@router.get("/browse-folders", response_model = BrowseFoldersResponse)
async def browse_folders(
def browse_folders(
path: Optional[str] = Query(
None,
description = (
@ -1450,11 +1508,22 @@ async def browse_folders(
then hidden (if ``show_hidden=true``).
"""
from utils.paths import hf_default_cache_dir, well_known_model_dirs
from utils.paths.external_media import linux_run_media_mount_roots
from storage.studio_db import contains_sensitive_path_component, list_scan_folders
from utils.paths.external_media import (
linux_run_media_mount_roots,
windows_drive_roots,
)
from storage.studio_db import (
contains_sensitive_path_component,
is_denied_system_path,
list_scan_folders,
)
# Probe removable-media and Windows drive roots once; the allowlist and
# chips reuse the result so a disconnected mapped drive isn't scanned twice.
media_roots = linux_run_media_mount_roots()
drive_roots = windows_drive_roots()
# Build once; the sandbox check and suggestion chips share it.
allowed_roots = _build_browse_allowlist()
allowed_roots = _build_browse_allowlist(media_roots, drive_roots)
try:
target = _resolve_browse_target(path, allowed_roots)
@ -1506,6 +1575,15 @@ async def browse_folders(
continue
if contains_sensitive_path_component(name):
continue
# Hide denied system dirs (C:\Windows, /etc, ...) so they don't
# render as clickable rows that then 403 on descent. Resolve first
# so a symlink/junction into a denied dir is hidden too, not just a literal name.
try:
resolved_child = os.path.realpath(str(child))
except (OSError, ValueError):
resolved_child = str(child)
if is_denied_system_path(resolved_child):
continue
entries.append(
BrowseEntry(
name = name,
@ -1553,13 +1631,22 @@ async def browse_folders(
return
if resolved in seen_sug:
return
# Drop a denied system dir (e.g. a stale scan-folder row) so it never
# becomes a chip that 403s on click. Drive roots stay: only their
# system subdirectories are denied, not the root itself.
if is_denied_system_path(resolved):
return
if _safe_is_dir(resolved):
seen_sug.add(resolved)
suggestions.append(resolved)
# Home first -- the safe fallback when everything else is cold.
_add_sug(Path.home())
for p in linux_run_media_mount_roots():
# Reuse the roots probed for the allowlist above (no second drive scan).
for p in media_roots:
_add_sug(p)
# Windows drive roots so the user can hop between C:, D:, E: ...
for p in drive_roots:
_add_sug(p)
# The HF cache root the process is actually using.
try:

View file

@ -1,6 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import re
from typing import Literal, Optional
from urllib.parse import unquote, urlsplit
@ -32,6 +33,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 +176,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),
@ -537,6 +552,7 @@ class PersonalizationProfile(BaseModel):
nickname: str = Field("", max_length = 200)
avatarDataUrl: Optional[str] = Field(None, max_length = MAX_AVATAR_DATA_URL_BYTES)
avatarShape: Literal["circle", "rounded"] = "circle"
showGreetingSloth: bool = True
@field_validator("avatarDataUrl")
@classmethod
@ -548,11 +564,179 @@ class PersonalizationProfile(BaseModel):
return value
class PersonalizationCustomColors(BaseModel):
model_config = ConfigDict(extra = "ignore")
accent: Optional[str] = Field(None, pattern = r"^#[0-9a-fA-F]{6}$")
background: Optional[str] = Field(None, pattern = r"^#[0-9a-fA-F]{6}$")
foreground: Optional[str] = Field(None, pattern = r"^#[0-9a-fA-F]{6}$")
class PersonalizationCustomColorModes(BaseModel):
model_config = ConfigDict(extra = "ignore")
light: PersonalizationCustomColors = Field(default_factory = PersonalizationCustomColors)
dark: PersonalizationCustomColors = Field(default_factory = PersonalizationCustomColors)
MAX_IMPORTED_FONTS = 3
# ~1.5 MB font file as base64; matches MAX_IMPORTED_FONT_DATA_URL_LENGTH in
# the frontend appearance-custom-store.
MAX_FONT_DATA_URL_LENGTH = 2_200_000
# Aggregate cap across all imported fonts; matches
# MAX_TOTAL_IMPORTED_FONT_DATA_URL_LENGTH in the frontend so a synced payload
# always fits the browser's localStorage quota.
MAX_TOTAL_FONT_DATA_URL_LENGTH = 4_400_000
# Characters that could terminate a CSS declaration, escape the quoted
# font-family value (backslash), or smuggle extra fallbacks/comments (comma,
# slash) if a stored name ever reached a stylesheet. The server is the
# authoritative gate; the frontend strips the same set before use.
_FONT_NAME_FORBIDDEN = set(";{}()<>\"'\\/,`")
def _check_font_name(value: str) -> str:
if any(c in _FONT_NAME_FORBIDDEN or ord(c) < 0x20 for c in value):
raise ValueError("Font name contains invalid characters.")
return value
# Matches FONT_DATA_URL_PATTERN in the frontend appearance-custom-store.
_FONT_DATA_URL_PATTERN = re.compile(
r"^data:(?:font/(?:woff2?|ttf|otf|sfnt)"
r"|application/(?:octet-stream|x-font-\w+|font-\w+));base64,[A-Za-z0-9+/=]+$"
)
class PersonalizationImportedFont(BaseModel):
model_config = ConfigDict(extra = "ignore")
name: str = Field(..., min_length = 1, max_length = 100)
dataUrl: str = Field(..., max_length = MAX_FONT_DATA_URL_LENGTH)
@field_validator("name")
@classmethod
def _validate_font_name(cls, value: str) -> str:
return _check_font_name(value)
@field_validator("dataUrl")
@classmethod
def _validate_font_data_url(cls, value: str) -> str:
# fullmatch, not match: re's ``$`` also matches just before a trailing
# newline, so ``match`` would accept "data:font/woff2;base64,AAAA\n",
# which the frontend's JS pattern (``$`` = end of string) rejects.
if not _FONT_DATA_URL_PATTERN.fullmatch(value):
raise ValueError("dataUrl must be a base64 font data URL.")
return value
# Optional user-menu items; the boolean is each id's default visibility.
# Settings-tab shortcuts ship hidden.
SIDEBAR_MENU_ITEM_DEFAULTS = {
"api": True,
"darkMode": True,
"guidedTour": True,
"profile": False,
"appearance": False,
"resources": False,
"chat": False,
"connections": False,
}
# The sidebarMenu validator below dedupes ids and re-fills any missing ones, so
# the stored list is always exactly one entry per id. Cap the *incoming* list at
# a generous multiple rather than len(defaults): a stale or duplicated payload
# (more items than distinct ids) must reach the validator so it can normalize,
# instead of being rejected by the length constraint before dedupe runs. A
# pathologically long list is still refused.
MAX_SIDEBAR_MENU_INPUT_ITEMS = 4 * len(SIDEBAR_MENU_ITEM_DEFAULTS)
class PersonalizationSidebarMenuItem(BaseModel):
model_config = ConfigDict(extra = "ignore")
id: Literal[
"api",
"darkMode",
"guidedTour",
"profile",
"appearance",
"resources",
"chat",
"connections",
]
visible: bool = True
def _default_sidebar_menu() -> "list[PersonalizationSidebarMenuItem]":
return [
PersonalizationSidebarMenuItem(id = item_id, visible = visible)
for item_id, visible in SIDEBAR_MENU_ITEM_DEFAULTS.items()
]
class PersonalizationCustomization(BaseModel):
model_config = ConfigDict(extra = "ignore")
colors: PersonalizationCustomColorModes = Field(default_factory = PersonalizationCustomColorModes)
uiFont: Optional[str] = Field(None, max_length = 200)
headingFont: Optional[str] = Field(None, max_length = 200)
chatFont: Optional[str] = Field(None, max_length = 200)
codeFont: Optional[str] = Field(None, max_length = 200)
importedFonts: list[PersonalizationImportedFont] = Field(
default_factory = list, max_length = MAX_IMPORTED_FONTS
)
@field_validator("importedFonts")
@classmethod
def _validate_total_font_size(
cls, value: list[PersonalizationImportedFont]
) -> list[PersonalizationImportedFont]:
if sum(len(f.dataUrl) for f in value) > MAX_TOTAL_FONT_DATA_URL_LENGTH:
raise ValueError("Imported fonts exceed the total size limit.")
return value
@field_validator("uiFont", "headingFont", "chatFont", "codeFont")
@classmethod
def _validate_selected_fonts(cls, value: Optional[str]) -> Optional[str]:
# Selected font names reach CSS the same way imported names do.
return value if value is None else _check_font_name(value)
uiFontSize: Optional[int] = Field(None, ge = 12, le = 20)
codeFontSize: Optional[int] = Field(None, ge = 10, le = 20)
contrast: int = Field(50, ge = 0, le = 100)
pointerCursors: bool = False
reduceMotion: Literal["system", "on", "off"] = "system"
fontSmoothing: bool = True
sidebarMenu: list[PersonalizationSidebarMenuItem] = Field(
default_factory = _default_sidebar_menu,
max_length = MAX_SIDEBAR_MENU_INPUT_ITEMS,
)
@field_validator("sidebarMenu")
@classmethod
def _validate_sidebar_menu(
cls, value: list[PersonalizationSidebarMenuItem]
) -> list[PersonalizationSidebarMenuItem]:
# Drop duplicate ids (keep the first) and re-append any missing ids so
# the stored list always covers every optional menu item exactly once.
seen: set[str] = set()
items = [item for item in value if not (item.id in seen or seen.add(item.id))]
for item_id, visible in SIDEBAR_MENU_ITEM_DEFAULTS.items():
if item_id not in seen:
items.append(PersonalizationSidebarMenuItem(id = item_id, visible = visible))
return items
class PersonalizationAppearance(BaseModel):
model_config = ConfigDict(extra = "ignore")
theme: Literal["light", "dark", "system"] = "system"
palette: Literal["standard", "classic", "minimal"] = "standard"
language: Optional[str] = Field(None, max_length = 20)
customization: PersonalizationCustomization = Field(
default_factory = PersonalizationCustomization
)
class PersonalizationPayload(BaseModel):
@ -565,6 +749,11 @@ class PersonalizationPayload(BaseModel):
class PersonalizationResponse(PersonalizationPayload):
saved: bool = False
# False when the stored record predates a field, so the client keeps local
# overrides instead of treating a server-filled default as an explicit value.
customizationSaved: bool = False
paletteSaved: bool = False
greetingSlothSaved: bool = False
@router.get("/personalization", response_model = PersonalizationResponse)
@ -574,15 +763,38 @@ def get_personalization_settings(
stored = get_personalization()
response = PersonalizationResponse.model_validate(stored or {})
response.saved = bool(stored)
appearance = stored.get("appearance") if isinstance(stored, dict) else None
profile = stored.get("profile") if isinstance(stored, dict) else None
response.customizationSaved = isinstance(appearance, dict) and "customization" in appearance
response.paletteSaved = isinstance(appearance, dict) and "palette" in appearance
response.greetingSlothSaved = isinstance(profile, dict) and "showGreetingSloth" in profile
return response
def _merge_personalization(base: dict, overlay: dict) -> dict:
# Recursively overlay only the request's set fields onto the stored record,
# so a stale client that omits newer keys (palette, customization) does not
# materialize their defaults and defeat the *Saved legacy detection.
merged = dict(base)
for key, value in overlay.items():
existing = merged.get(key)
if isinstance(value, dict) and isinstance(existing, dict):
merged[key] = _merge_personalization(existing, value)
else:
merged[key] = value
return merged
@router.put("/personalization", response_model = PersonalizationPayload)
def update_personalization_settings(
payload: PersonalizationPayload, current_subject: str = Depends(get_current_subject)
) -> PersonalizationPayload:
try:
set_personalization(payload.model_dump())
# exclude_unset so absent fields are not persisted as defaults; merge so
# fields the request omits keep whatever the record already stored.
incoming = payload.model_dump(exclude_unset = True)
merged = _merge_personalization(get_personalization(), incoming)
set_personalization(merged)
except ValueError as exc:
raise log_and_http_error(
exc,
@ -591,4 +803,6 @@ def update_personalization_settings(
event = "settings.update_personalization_failed",
log = logger,
) from exc
return payload
# Return the stored record, not the defaults-filled request, so the response
# matches storage (and the next GET) for fields the client omitted.
return PersonalizationPayload.model_validate(merged)

View file

@ -146,6 +146,16 @@ async def start_training(
# No in-process ensure_transformers_version(): the subprocess
# (worker.py) activates the correct version before importing ML libs.
# A consented latest-transformers install stage-and-swaps .venv_t5_latest;
# a worker spawned mid-swap could activate a half-replaced sidecar.
from utils.transformers_latest import is_install_in_progress
if is_install_in_progress():
raise HTTPException(
status_code = 409,
detail = ("A transformers installation is in progress. Retry when it completes."),
)
backend = get_training_backend()
# S3 dataset loading needs the optional boto3 dependency. Reject early
@ -341,6 +351,24 @@ async def start_training(
"s3_config": request.s3_config.model_dump() if request.s3_config else None,
}
# Latest-sidecar models size and train 16-bit (same flip as chat load):
# 4-bit is disabled for brand-new architectures, so VRAM coexistence
# checks must not underestimate against a load the worker will refuse.
if training_kwargs["load_in_4bit"]:
from utils.transformers_version import latest_tier_active_for
if await asyncio.to_thread(
latest_tier_active_for,
training_kwargs["model_name"],
training_kwargs["hf_token"] or None,
):
training_kwargs["load_in_4bit"] = False
logger.info(
"Latest-transformers sidecar active for %s - sizing and "
"training in 16-bit (4-bit is disabled for brand-new "
"architectures)",
training_kwargs["model_name"],
)
# Training page has no trust_remote_code toggle, so honor the YAML default
# -- but only for genuine first-party (unsloth/nvidia) Hub repos, never a
# local path or a name merely starting with "unsloth/".
@ -426,9 +454,16 @@ async def start_training(
logger.warning("Chat/training VRAM coordination failed; proceeding: %s", e)
# The hook runs only once start guards pass -> VRAM freed iff training starts.
success = backend.start_training(
job_id = job_id, before_spawn = _free_vram_for_training, **training_kwargs
)
from utils.transformers_version import SidecarSwapInProgress
try:
success = backend.start_training(
job_id = job_id, before_spawn = _free_vram_for_training, **training_kwargs
)
except SidecarSwapInProgress as exc:
# Expected loss of the race against a sidecar install: a retryable
# 409 matching the route-entry guard, not an internal error.
raise HTTPException(status_code = 409, detail = str(exc))
if not success:
progress_error = backend.trainer.training_progress.error
@ -698,7 +733,9 @@ async def stream_training_progress(
if last_event_id is not None:
try:
resume_from_step = int(last_event_id)
logger.info(f"SSE reconnect: resuming from step {resume_from_step}")
# Fires on every reconnect (each tab switch); the meaningful signal is
# the "replayed N missed steps" line below, logged only when N > 0.
logger.debug(f"SSE reconnect: resuming from step {resume_from_step}")
except ValueError:
logger.warning(f"Invalid Last-Event-ID: {last_event_id}")

View file

@ -10,7 +10,7 @@ import os
import sys
import time
from pathlib import Path
from typing import Optional
from typing import Optional, Tuple
def _fix_torch_cuda_ld_path():
@ -616,24 +616,28 @@ def _print_cloudflare_line(secure: bool = False, loopback_host: str = "127.0.0.1
f"bind {loopback_host} or close firewall access to keep Studio private.",
warn,
)
elif not _cloudflare_flag:
elif _cloudflare_flag is False or _cloudflare_flag is None:
# None = off by default (no flag); False = explicit --no-cloudflare.
_reason = "default" if _cloudflare_flag is None else "--no-cloudflare"
if _public_reachable is True:
_emit(
" Cloudflare tunnel: OFF (--no-cloudflare). The raw port is still "
f" Cloudflare tunnel: OFF ({_reason}). The raw port is still "
"reachable from the public internet (see the reachability check above): "
"--no-cloudflare disables only the Cloudflare link, not the public bind.",
"pass --cloudflare to also expose a public Cloudflare HTTPS link, or "
f"bind {loopback_host} to keep Studio private.",
warn,
)
elif _public_reachable is False:
_emit(
" Cloudflare tunnel: OFF (--no-cloudflare). Studio is reachable on your "
"local network only. Omit --no-cloudflare to expose a public "
f" Cloudflare tunnel: OFF ({_reason}). Studio is reachable on your "
"local network only. Pass --cloudflare to expose a public "
"Cloudflare HTTPS link."
)
else:
_emit(
" Cloudflare tunnel: OFF (--no-cloudflare). There is no Cloudflare "
"public link. Raw port reachability was not verified; "
f" Cloudflare tunnel: OFF ({_reason}). There is no Cloudflare "
"public link. Raw port reachability was not verified; pass --cloudflare "
"to expose a public Cloudflare HTTPS link, or "
f"bind {loopback_host} or close firewall access to keep Studio private.",
warn,
)
@ -874,7 +878,9 @@ _cloudflare_url = None
_public_reachable = None
_cloudflare_requested = False
_cloudflare_flag = True
# Opt-in tri-state (mirrors the CLI): None = off by default, True = on,
# False = explicit --no-cloudflare. run_server overwrites it before the banner.
_cloudflare_flag = None
_DEFAULT_FRONTEND_PATH = Path(__file__).resolve().parent.parent / "frontend" / "dist"
@ -1057,6 +1063,199 @@ def _cloudflare_tunnel_should_start(
return host in ("0.0.0.0", "::") and not api_only
def _stream_isatty(stream) -> bool:
"""isatty() that treats broken streams as non-interactive.
isatty() can raise under service wrappers (closed stdin -> ValueError;
sys.stdin None in Windows GUI -> AttributeError); such a stream can't host a
prompt, which is a fallback, not an error.
"""
try:
return stream.isatty()
except (AttributeError, ValueError):
return False
def _terminal_password_gate(
*,
tunnel_will_start: bool,
host: str,
secure: bool,
api_only: bool,
frontend_served: bool,
is_colab: bool = False,
) -> Tuple[bool, bool]:
"""Force a terminal password change before the public tunnel goes up.
When the tunnel is about to publish Studio and the seeded admin password was
never changed, ask for a new one (masked, confirmed) before any public URL
exists. The CLI normally does this before re-exec'ing the backend; this is
the backstop for direct `python run.py` launches and older-CLI installs.
Must run BEFORE the uvicorn socket binds: on a wildcard bind the served HTML
injects the bootstrap credential, so a pre-gate listener would hand the
default password to anyone reaching the raw port while the operator types.
Returns (proceed, drop_bootstrap_injection):
proceed False -> abort the launch (interactive refusal, or a headless
public launch nothing would protect); fail closed.
drop_bootstrap_injection True -> caller must null
app.state.bootstrap_password: the password just changed (stale), or a
public URL is about to serve the default credential and must not leak it.
Without a usable terminal the prompt is skipped: proceed if the bootstrap
deadline (armed later) will protect the launch; if even that is disabled
(api-only, timeout 0) nothing protects it, so refuse. NOT wrapped in a broad
try/except: an auth storage failure must abort rather than expose the default.
"""
if not tunnel_will_start:
return True, False
from auth import hashing as _auth_hashing
from auth import storage as _auth_storage
from auth.bootstrap_timeout import (
bootstrap_timeout_seconds,
should_arm_bootstrap_timeout,
)
from auth.terminal_prompt import (
prompt_for_password_change,
should_prompt_password_change,
)
_admin = _auth_storage.DEFAULT_ADMIN_USERNAME
# Gate can run before lifespan: seed the admin row here (idempotent).
_auth_storage.ensure_default_admin()
requires_change = _auth_storage.requires_password_change(_admin)
if not requires_change:
return True, False
if not should_prompt_password_change(
tunnel_will_start = tunnel_will_start,
requires_change = requires_change,
stdin_isatty = _stream_isatty(sys.stdin),
stderr_isatty = _stream_isatty(sys.stderr),
):
# No terminal: only proceed if the bootstrap deadline will arm; api-only
# and TIMEOUT=0 never arm it, leaving the default credential public.
deadline_arms = should_arm_bootstrap_timeout(
host = host,
secure = secure,
api_only = api_only,
frontend_served = frontend_served,
is_colab = is_colab,
requires_change = True,
timeout_seconds = bootstrap_timeout_seconds(),
)
if not deadline_arms:
print(
"Refusing to publish Studio on a public Cloudflare URL: the "
"default admin password was never changed, no terminal is "
"attached to change it here, and the bootstrap shutdown "
"deadline does not apply to this launch (api-only, or "
"UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT=0). Change the password "
"first (run `unsloth studio` locally and log in, or re-run "
"with a terminal attached), then retry.",
file = sys.stderr,
flush = True,
)
return False, False
# The public page won't auto-fill the bootstrap credential (suppressed
# below) and the seeded file may already be gone, so point recovery at a
# terminal-attached run / reset-password instead of reading it from disk.
print(
" WARNING: the default admin password is still active while "
"Studio is about to be published on a public Cloudflare URL, and "
"no terminal is attached to change it here. The public page will "
"NOT auto-fill the bootstrap credential. Set a new password by "
"running `unsloth studio` locally with a terminal attached, or "
"`unsloth studio reset-password`. Studio shuts down after the "
"bootstrap deadline (UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT, default 1h) "
"unless the password is changed.",
file = sys.stderr,
flush = True,
)
# Never serve the default credential in HTML over a public URL.
return True, True
def _is_current_password(candidate: str) -> bool:
record = _auth_storage.get_user_and_secret(_admin)
if record is None:
return False
salt, pwd_hash, _jwt_secret, _must_change = record
return _auth_hashing.verify_password(candidate, salt, pwd_hash)
def _apply_change(new_password: str) -> None:
# Same effects as routes/auth.py change_password: rehash, rotate the JWT
# secret, revoke refresh tokens in the SAME transaction.
_auth_storage.update_password(_admin, new_password, revoke_refresh_tokens = True)
changed = prompt_for_password_change(
min_length = _auth_storage.MIN_PASSWORD_LENGTH,
is_current_password = _is_current_password,
apply_change = _apply_change,
out = sys.stderr,
)
return (True, True) if changed else (False, False)
def _apply_supplied_password(password_value: "Optional[str]") -> None:
"""Non-interactively set the INITIAL admin password before the socket binds,
for a direct ``python run.py`` launch (the CLI does this in its own parent).
Value comes from --password / UNSLOTH_STUDIO_PASSWORD / stdin.
Only ever sets the FIRST password: an already-set one is a hard error, an
invalid value fails closed. NOT wrapped in a broad try/except: an auth
storage failure must abort rather than expose the default credential.
"""
from auth import hashing as _auth_hashing
from auth import storage as _auth_storage
from auth.terminal_prompt import SUPPLIED_PASSWORD_ENV, resolve_supplied_password
supplied = resolve_supplied_password(password_value)
# Strip the env var once read so child subprocesses (cloudflared, llama-server,
# code-exec tools) can't inherit the plaintext via /proc/PID/environ. Mirrors
# the CLI. Unconditional: strips a leftover value even when a literal --password won.
os.environ.pop(SUPPLIED_PASSWORD_ENV, None)
if not supplied:
return
_admin = _auth_storage.DEFAULT_ADMIN_USERNAME
_auth_storage.ensure_default_admin()
if not _auth_storage.requires_password_change(_admin):
print(
"Error: a Studio admin password is already set; --password only sets "
"the initial password. Run `unsloth studio reset-password` first.",
file = sys.stderr,
flush = True,
)
sys.exit(1)
def _is_current_password(candidate: str) -> bool:
record = _auth_storage.get_user_and_secret(_admin)
if record is None:
return False
salt, pwd_hash, _jwt_secret, _must_change = record
return _auth_hashing.verify_password(candidate, salt, pwd_hash)
if len(supplied) < _auth_storage.MIN_PASSWORD_LENGTH:
print(
f"Error: password must be at least {_auth_storage.MIN_PASSWORD_LENGTH} "
"characters; not starting.",
file = sys.stderr,
flush = True,
)
sys.exit(1)
if _is_current_password(supplied):
print(
"Error: the new password must differ from the current bootstrap "
"password; not starting.",
file = sys.stderr,
flush = True,
)
sys.exit(1)
_auth_storage.update_password(_admin, supplied, revoke_refresh_tokens = True)
print(f"Password updated for '{_admin}'.", file = sys.stderr, flush = True)
def _apply_cli_tool_policy(enable_tools: "Optional[bool]") -> None:
"""Honor an explicit --enable-tools/--disable-tools; None leaves the policy
unset (tools default on, per-request enable_tools honored). Host is never
@ -1075,9 +1274,10 @@ def run_server(
silent: bool = False,
api_only: bool = False,
llama_parallel_slots: int = 1,
cloudflare: bool = True,
cloudflare: "Optional[bool]" = None,
secure: bool = False,
enable_tools: "Optional[bool]" = None,
password: "Optional[str]" = None,
emit_tauri_port: bool = True,
):
"""
@ -1090,6 +1290,9 @@ def run_server(
silent: Suppress startup messages
api_only: API server only, no frontend (for Tauri desktop app)
llama_parallel_slots: parallel slots for llama-server
cloudflare: opt in to the public Cloudflare HTTPS tunnel for a wildcard
bind. Tri-state: None (unset) and False both mean off; True enables it.
--secure implies it (True) and rejects an explicit False.
enable_tools: explicit --enable-tools/--disable-tools policy; None leaves
the default (tools on, per-request enable_tools honored)
emit_tauri_port: print the machine-readable TAURI_PORT line the desktop
@ -1111,13 +1314,16 @@ def run_server(
initialize_parent_lifetime()
# --secure exposes only the Cloudflare link: force a loopback bind so the raw
# port is never public (even with -H 0.0.0.0), and reject the contradictory combo.
if secure and not cloudflare:
raise SystemExit(
"A secure Cloudflare link is not allowed, use --no-secure which provides a 0.0.0.0 link"
)
# --secure exposes ONLY the Cloudflare link: reject --secure --no-cloudflare,
# then force a loopback bind so the raw port is never public (even -H 0.0.0.0).
# Otherwise keep the tri-state so the banner distinguishes "off by default"
# from an explicit --no-cloudflare.
if secure:
if cloudflare is False:
raise SystemExit(
"--secure requires the Cloudflare tunnel; do not combine it with --no-cloudflare."
)
cloudflare = True
host = "127.0.0.1"
# `unsloth studio run` installs its own resolved policy and passes None here.
@ -1156,6 +1362,15 @@ def run_server(
from threading import Thread, Event
import uvicorn
# `from main import app` below loads torch/unsloth/transformers (~2 min cold,
# silent), so print a flushed heads-up (piped stdout is block-buffered).
if not silent:
print(
"Loading Unsloth Studio, please wait... (this can take a few minutes)",
flush = True,
)
print(" - loading PyTorch, Unsloth and Transformers...", flush = True)
import_started = time.perf_counter()
from main import app, setup_frontend, _IS_COLAB
@ -1164,6 +1379,8 @@ def run_server(
"Imported FastAPI app in %.1fms",
(time.perf_counter() - import_started) * 1000,
)
if not silent:
print(" - Starting server...", flush = True)
from utils.paths import ensure_studio_directories
# Allow local stdio MCP servers on a loopback bind (the user's own machine),
@ -1191,7 +1408,7 @@ def run_server(
print("=" * 50)
if blocker:
pid, name = blocker
print(f"Port {original_port} is already in use by " f"{name} (PID {pid}).")
print(f"Port {original_port} is already in use by {name} (PID {pid}).")
else:
print(f"Port {original_port} is already in use.")
print(f"Unsloth Studio will use port {port} instead.")
@ -1304,6 +1521,44 @@ def run_server(
app.state.trigger_shutdown = _trigger_shutdown
# A supplied --password / UNSLOTH_STUDIO_PASSWORD / stdin sets the initial
# admin password before the gate and socket bind (direct `python run.py`;
# the CLI applies it in its own parent).
_apply_supplied_password(password)
# Never publish with the seeded default password active: prompt first (or
# warn / fail closed headless; see _terminal_password_gate). Runs BEFORE the
# socket binds so a pre-gate listener can't hand out the injected credential.
_pw_proceed, _pw_drop_bootstrap = _terminal_password_gate(
tunnel_will_start = _cloudflare_tunnel_should_start(
cloudflare = cloudflare,
host = host,
secure = secure,
api_only = api_only,
is_colab = _IS_COLAB,
),
host = host,
secure = secure,
api_only = api_only,
frontend_served = bool(frontend_path) and not api_only,
is_colab = _IS_COLAB,
)
if not _pw_proceed:
print(
"Not starting Studio; set a new admin password first, or launch "
"without --secure/--cloudflare.",
file = sys.stderr,
flush = True,
)
sys.exit(1)
if _pw_drop_bootstrap:
# Password just changed (stale) or a public URL is about to serve the
# default credential: don't leak it in the HTML. Lifespan runs AFTER this
# and re-reads the bootstrap password, so the flag (not a plain None)
# makes it skip that re-read.
app.state.suppress_bootstrap_injection = True
app.state.bootstrap_password = None
# Run server in a daemon thread with explicit new_event_loop() +
# run_until_complete() (not asyncio.run) so nest_asyncio's patches don't
# interfere when Colab/IPython already runs a loop on the main thread.
@ -1373,6 +1628,7 @@ def run_server(
is_colab = _IS_COLAB,
)
_cloudflare_requested = _cloudflare_enabled
if _cloudflare_enabled:
try: # best-effort: any failure must not block startup
from cloudflare_tunnel import start_studio_tunnel, stop_studio_tunnel
@ -1460,6 +1716,14 @@ def _build_arg_parser():
default = "127.0.0.1",
help = "Host to bind to (default: 127.0.0.1; use 0.0.0.0 for network/cloud access)",
)
parser.add_argument(
"--password",
default = None,
help = "Set the INITIAL admin password non-interactively (headless), only when "
"none is set yet. Also reads UNSLOTH_STUDIO_PASSWORD, or --password - for stdin. "
"A literal value is visible in the process list. Rotate later via "
"`unsloth studio reset-password`.",
)
parser.add_argument("--port", type = int, default = 8888, help = "Port to bind to")
parser.add_argument(
"--frontend",
@ -1476,11 +1740,13 @@ def _build_arg_parser():
parser.add_argument(
"--cloudflare",
action = argparse.BooleanOptionalAction,
default = True,
help = "Auto-create a free Cloudflare HTTPS tunnel for non-api-only wildcard "
"binds (0.0.0.0 or ::), exposing Studio on a PUBLIC internet URL (default on). "
"Pass --no-cloudflare to disable that Cloudflare URL; it does not change a "
"public wildcard bind. --api-only keeps it off unless paired with --secure.",
default = None,
help = "Expose Studio on a PUBLIC internet URL via a free Cloudflare HTTPS "
"tunnel, for non-api-only wildcard binds (0.0.0.0 or ::). Off by default; "
"pass --cloudflare to enable it (--secure implies it), --no-cloudflare to "
"force it off. It does not change a raw wildcard bind. If the admin "
"password was never changed, Studio asks for a new one in the terminal "
"before publishing the URL.",
)
parser.add_argument(
"--secure",
@ -1488,7 +1754,9 @@ def _build_arg_parser():
default = False,
help = "Expose ONLY a Cloudflare HTTPS link: bind localhost and fail closed "
"if the tunnel can't start. Without it, --no-secure also serves the raw "
"0.0.0.0 port, which is reachable from anywhere on the network",
"0.0.0.0 port, which is reachable from anywhere on the network. If the "
"admin password was never changed, Studio asks for a new one in the "
"terminal before publishing the URL.",
)
# Back-compat: accept --not-secure as a hidden alias for --no-secure.
parser.add_argument(
@ -1550,7 +1818,7 @@ if __name__ == "__main__":
args = parser.parse_args()
if not _PARALLEL_MIN <= args.parallel <= _PARALLEL_MAX:
parser.error(f"--parallel must be between {_PARALLEL_MIN} and {_PARALLEL_MAX}")
if args.secure and not args.cloudflare:
if args.secure and args.cloudflare is False:
parser.error(
"--secure requires the Cloudflare tunnel; do not combine it with --no-cloudflare"
)
@ -1564,6 +1832,7 @@ if __name__ == "__main__":
cloudflare = args.cloudflare,
secure = args.secure,
enable_tools = args.enable_tools,
password = args.password,
)
if args.frontend is not None:
kwargs["frontend_path"] = Path(args.frontend)

View file

@ -27,7 +27,7 @@ from utils.paths import (
project_workspaces_root,
studio_db_path,
)
from utils.paths.external_media import is_linux_run_media_path
from utils.paths.external_media import is_linux_run_media_path, is_local_filesystem_root
from utils.paths.sensitive import (
contains_sensitive_path_component as _shared_contains_sensitive_path_component,
)
@ -69,6 +69,25 @@ def _denied_path_prefixes() -> list[str]:
return []
def is_denied_system_path(path: str) -> bool:
"""True if *path* is, or descends from, a denied system directory.
Mirrors the denylist add_scan_folder() enforces at registration so the
browser refuses /etc, /proc, C:\\Windows, etc. even when the allowlist holds
a broad root (a Windows drive root C:\\ or a legacy-registered / root). The
/run carve-out keeps Linux removable-media mounts browseable. Expects an
already-resolved (realpath) path so symlinks cannot escape into a denied subtree.
"""
is_win = platform.system() == "Windows"
check = os.path.normcase(path) if is_win else path
for prefix in _denied_path_prefixes():
if check == prefix or check.startswith(prefix + os.sep):
if prefix == "/run" and is_linux_run_media_path(check):
continue
return True
return False
def _contains_sensitive_path_component(path: str) -> bool:
return _shared_contains_sensitive_path_component(path)
@ -931,6 +950,12 @@ def add_scan_folder(path: str) -> dict:
raise ValueError("Path must be a directory, not a file")
if not os.access(normalized, os.R_OK | os.X_OK):
raise ValueError("Path is not readable")
# Reject a local filesystem root ("/", or a bare Windows drive root "C:\\"):
# registering one seeds the browse allowlist with a root above denied system
# dirs. A UNC share root (\\server\share) has none under it and was
# registerable before this guard, so it stays allowed. Mirrors scan_folders.py.
if is_local_filesystem_root(normalized):
raise ValueError("The filesystem root cannot be registered")
if _contains_sensitive_path_component(normalized):
raise ValueError("Credential or configuration directories are not allowed")

View file

@ -1631,6 +1631,48 @@ class TestAnthropicMessagesToolRouting:
assert entry["status"] == "cancelled"
assert monitor.active_count() == 0
@staticmethod
def _sse_blob(chunks):
# StreamingResponse may hand back str or already-encoded bytes.
return "".join(c.decode() if isinstance(c, (bytes, bytearray)) else c for c in chunks)
def test_plain_streaming_unclassified_error_emits_error_event(self, monkeypatch):
# An unclassified mid-stream failure must surface as an SSE `error` event
# and stop, not a message_stop that masks a truncated turn as clean.
def _gen_boom(**_kwargs):
yield "partial"
raise RuntimeError("llama-server crashed mid-decode")
_mock_backend(monkeypatch, generate_chat_completion = _gen_boom)
payload = _basic_payload(stream = True)
response = _drive(anthropic_messages(payload, request = self._Request(), current_subject = "t"))
blob = self._sse_blob(self._consume_response(response))
assert "event: error" in blob
assert '"type": "error"' in blob
assert "event: message_stop" not in blob
def test_tool_streaming_unclassified_error_emits_error_event(self, monkeypatch):
# Same guarantee on the tool-calling stream path.
def _gen_tools_boom(**_kwargs):
yield {"type": "content", "text": "partial"}
raise RuntimeError("llama-server crashed mid-decode")
_mock_backend(monkeypatch, generate_chat_completion_with_tools = _gen_tools_boom)
payload = _basic_payload(
stream = True,
enable_tools = True,
tools = [{"type": "web_search_20250305", "name": "web_search"}],
)
response = _drive(anthropic_messages(payload, request = self._Request(), current_subject = "t"))
blob = self._sse_blob(self._consume_response(response))
assert "event: error" in blob
assert '"type": "error"' in blob
assert "event: message_stop" not in blob
def test_mixed_server_and_client_tools_rejected_with_400(self, monkeypatch):
_mock_backend(monkeypatch)
payload = _basic_payload(
@ -1739,7 +1781,9 @@ class TestAnthropicMessagesToolRouting:
assert backend.calls[0][0] == "plain"
def test_server_tool_alias_enters_tool_path_when_policy_unset(self, monkeypatch):
# Mirror of the previous test for the default (None) policy.
# Mirror of the previous test for the default (None) policy. An omitted
# permission_mode still runs here because web_search is a safe server tool
# (only a selected terminal/python would require the missing gate).
backend = _mock_backend(monkeypatch)
payload = _basic_payload(
tools = [{"type": "web_search_20250305", "name": "web_search"}],
@ -1761,6 +1805,126 @@ class TestAnthropicMessagesToolRouting:
assert "confirm_tool_calls is not supported" in exc.value.detail["error"]["message"]
assert backend.calls == []
def test_permission_mode_gating_for_server_tools(self, monkeypatch):
# ask is a request for a per-call pause this channel cannot honor, so it is
# always rejected, even for a safe-only server tool (web_search).
safe_tools = [{"type": "web_search_20250305", "name": "web_search"}]
backend = _mock_backend(monkeypatch)
payload = _basic_payload(tools = safe_tools, permission_mode = "ask")
with pytest.raises(HTTPException) as exc:
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
assert exc.value.status_code == 400
assert "no confirmation channel" in exc.value.detail["error"]["message"]
assert backend.calls == []
# auto only gates unsafe calls, so a safe-only selection runs (nothing to
# gate), like the omitted default. Both keep existing callers working.
for extra in ({"permission_mode": "auto"}, {}):
backend = _mock_backend(monkeypatch)
payload = _basic_payload(tools = safe_tools, **extra)
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
assert backend.calls[0][0] == "tools"
# But auto or an omitted mode that would run a local tool (terminal/python,
# via a bare Anthropic tool type or enabled_tools) is rejected, since that
# tool could need the gate this channel lacks.
for local_payload in (
_basic_payload(tools = [{"type": "terminal", "name": "terminal"}]),
_basic_payload(
tools = [{"type": "terminal", "name": "terminal"}], permission_mode = "auto"
),
_basic_payload(tools = safe_tools, enable_tools = True, enabled_tools = ["python"]),
):
backend = _mock_backend(monkeypatch)
with pytest.raises(HTTPException) as exc:
_drive(anthropic_messages(local_payload, request = None, current_subject = "t"))
assert exc.value.status_code == 400
assert "terminal" in exc.value.detail["error"]["message"]
assert backend.calls == []
# off, full, and a legacy confirm_tool_calls=False opt-out all run, even
# with a local tool selected. The explicit opt-out wins over the mode
# (mirrors _permission_mode_confirm and the GGUF path), so it runs even
# under ask, which otherwise always rejects.
for extra in (
{"tools": safe_tools, "permission_mode": "off"},
{"tools": safe_tools, "permission_mode": "full"},
{"tools": safe_tools, "enabled_tools": ["python"], "confirm_tool_calls": False},
{"tools": safe_tools, "permission_mode": "ask", "confirm_tool_calls": False},
{
"tools": [{"type": "terminal", "name": "terminal"}],
"permission_mode": "ask",
"confirm_tool_calls": False,
},
):
backend = _mock_backend(monkeypatch)
payload = _basic_payload(**extra)
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
assert backend.calls[0][0] == "tools"
def test_render_html_gated_for_server_tools(self, monkeypatch):
# render_html is no longer unconditionally safe: a networked canvas prompts
# in auto and this channel cannot present that gate, so selecting it under
# ask/auto/omitted rejects like terminal/python; off/full (and an explicit
# confirm opt-out) run it.
rh = {"enable_tools": True, "enabled_tools": ["render_html"]}
for mode in ("ask", "auto", None):
backend = _mock_backend(monkeypatch)
fields = dict(rh)
if mode is not None:
fields["permission_mode"] = mode
payload = _basic_payload(**fields)
with pytest.raises(HTTPException) as exc:
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
assert exc.value.status_code == 400
assert "no confirmation channel" in exc.value.detail["error"]["message"]
assert backend.calls == []
for extra in (
{"permission_mode": "off"},
{"permission_mode": "full"},
{"confirm_tool_calls": False},
):
backend = _mock_backend(monkeypatch)
payload = _basic_payload(**{**rh, **extra})
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
assert backend.calls[0][0] == "tools"
def test_permission_mode_rejected_before_auto_switch(self, monkeypatch):
# The unsupported-mode rejection must run before _maybe_auto_switch_model,
# so an invalid confirm-gated request never evicts the resident model
# (mirrors the pre-switch malformed- and mixed-tool guards).
import routes.inference as inf_mod
switch_calls = []
async def _rec_switch(*_args, **_kwargs):
switch_calls.append(1)
monkeypatch.setattr(inf_mod, "_maybe_auto_switch_model", _rec_switch)
safe_tools = [{"type": "web_search_20250305", "name": "web_search"}]
local_tools = [{"type": "terminal", "name": "terminal"}]
# ask (any server tool), auto with a local tool, and an omitted mode
# selecting a local tool are all rejected up front, before the switch runs.
for payload in (
_basic_payload(tools = safe_tools, permission_mode = "ask"),
_basic_payload(tools = local_tools, permission_mode = "auto"),
_basic_payload(tools = local_tools),
):
switch_calls.clear()
_mock_backend(monkeypatch)
with pytest.raises(HTTPException) as exc:
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
assert exc.value.status_code == 400
assert switch_calls == [], "rejection must precede the auto-switch"
# A supported request (off) still reaches the switch and runs the loop.
switch_calls.clear()
_mock_backend(monkeypatch)
payload = _basic_payload(tools = safe_tools, permission_mode = "off")
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
assert switch_calls == [1]
def test_per_request_enable_tools_false_blocks_server_tool_alias(self, monkeypatch):
backend = _mock_backend(monkeypatch)
payload = _basic_payload(
@ -1770,3 +1934,523 @@ 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
def test_disable_parallel_tool_use_forwards_heartbeats_while_dropping():
"""Heartbeats from a parallel-disabled, dropped tool call must still reach
the client as SSE keepalives: the dropped call runs server-side and the
stall keepalive never fires while the generator keeps producing events, so
swallowing them recreates the silent window keepalives exist to prevent."""
import threading as _threading
from routes.inference import (
_OPENAI_PASSTHROUGH_SSE_KEEPALIVE,
_anthropic_tool_stream,
)
def run_gen():
def gen():
yield {
"type": "tool_start",
"tool_name": "python",
"tool_call_id": "call_0",
"arguments": {},
}
yield {"type": "heartbeat"}
yield {
"type": "tool_end",
"tool_name": "python",
"tool_call_id": "call_0",
"result": "r1",
}
# Second call: dropped by disable_parallel_tool_use, still executed
# server-side (heartbeats + live output).
yield {
"type": "tool_start",
"tool_name": "python",
"tool_call_id": "call_1",
"arguments": {},
}
yield {"type": "heartbeat"}
yield {
"type": "tool_output",
"tool_name": "python",
"tool_call_id": "call_1",
"text": "x",
}
yield {"type": "heartbeat"}
yield {
"type": "tool_end",
"tool_name": "python",
"tool_call_id": "call_1",
"result": "r2",
}
yield {"type": "content", "text": "final answer"}
return gen()
async def _drive():
async def _is_disconnected():
return False
request = SimpleNamespace(is_disconnected = _is_disconnected)
resp = await _anthropic_tool_stream(
request,
_threading.Event(),
run_gen,
"msg_hb",
"m",
disable_parallel_tool_use = True,
)
return [chunk async for chunk in resp.body_iterator]
chunks = asyncio.run(_drive())
keepalives = [c for c in chunks if c == _OPENAI_PASSTHROUGH_SSE_KEEPALIVE]
# One heartbeat inside the kept call, two inside the dropped window.
assert len(keepalives) >= 3
# The dropped call must not surface as a second tool_use block.
tool_use_starts = [c for c in chunks if "content_block_start" in c and '"tool_use"' in c]
assert len(tool_use_starts) == 1
def test_dropped_tool_output_events_emit_rate_limited_keepalives(monkeypatch):
"""A chatty tool streaming tool_output/tool_args with no heartbeats keeps the
generator busy (stall keepalive never fires); the Anthropic path can't
translate those events and drops them. Dropping silently would let an idle
proxy kill the stream, so the drop branch emits a rate-limited keepalive."""
import threading as _threading
import routes.inference as inf_mod
from routes.inference import (
_OPENAI_PASSTHROUGH_SSE_KEEPALIVE,
_anthropic_tool_stream,
)
# Deterministic clock: only the drop-branch keepalive uses time.monotonic
# here, so jumping past the stall window per call makes each dropped event
# cross the rate-limit threshold. asyncio.wait uses the loop clock and
# next(gen) returns promptly, so the outer stall keepalive never fires --
# every keepalive here is from the drop branch.
_real_time = inf_mod.time
_tick = {"v": 0.0}
def _fast_monotonic():
_tick["v"] += 100.0
return _tick["v"]
fake_time = SimpleNamespace(
monotonic = _fast_monotonic,
sleep = _real_time.sleep,
time = _real_time.time,
perf_counter = _real_time.perf_counter,
)
monkeypatch.setattr(inf_mod, "time", fake_time)
n_output = 4
def run_gen():
def gen():
yield {
"type": "tool_start",
"tool_name": "python",
"tool_call_id": "call_0",
"arguments": {},
}
# Chatty streamed stdout, no heartbeats.
for i in range(n_output):
yield {
"type": "tool_output",
"tool_name": "python",
"tool_call_id": "call_0",
"text": f"line {i}\n",
}
yield {
"type": "tool_end",
"tool_name": "python",
"tool_call_id": "call_0",
"result": "done",
}
yield {"type": "content", "text": "final answer"}
return gen()
async def _drive():
async def _is_disconnected():
return False
request = SimpleNamespace(is_disconnected = _is_disconnected)
resp = await _anthropic_tool_stream(
request,
_threading.Event(),
run_gen,
"msg_drop_ka",
"m",
)
return [chunk async for chunk in resp.body_iterator]
chunks = asyncio.run(_drive())
keepalives = [c for c in chunks if c == _OPENAI_PASSTHROUGH_SSE_KEEPALIVE]
assert len(keepalives) == n_output
# Final answer still reaches the client (drop is transport-only).
assert any("final answer" in c for c in chunks)
def test_parallel_disabled_dropped_call_output_emits_rate_limited_keepalives(monkeypatch):
"""Under disable_parallel_tool_use a chatty second call is dropped whole
(drop_until_tool_end). Its tool_output/tool_args events must still emit
rate-limited keepalives: the drop window can last minutes with no heartbeats
and no stall keepalive, so swallowing them silently would let an idle proxy
kill the stream. The keepalive branch runs before the drop skip."""
import threading as _threading
import routes.inference as inf_mod
from routes.inference import (
_OPENAI_PASSTHROUGH_SSE_KEEPALIVE,
_anthropic_tool_stream,
)
# Deterministic clock: jumps past the stall window per call (see sibling test).
_real_time = inf_mod.time
_tick = {"v": 0.0}
def _fast_monotonic():
_tick["v"] += 100.0
return _tick["v"]
fake_time = SimpleNamespace(
monotonic = _fast_monotonic,
sleep = _real_time.sleep,
time = _real_time.time,
perf_counter = _real_time.perf_counter,
)
monkeypatch.setattr(inf_mod, "time", fake_time)
n_output = 4
def run_gen():
def gen():
# First (kept) call.
yield {
"type": "tool_start",
"tool_name": "python",
"tool_call_id": "call_0",
"arguments": {},
}
yield {
"type": "tool_end",
"tool_name": "python",
"tool_call_id": "call_0",
"result": "r1",
}
# Second call: dropped whole by disable_parallel_tool_use but still
# executed server-side, streaming chatty stdout with no heartbeats.
yield {
"type": "tool_start",
"tool_name": "python",
"tool_call_id": "call_1",
"arguments": {},
}
for i in range(n_output):
yield {
"type": "tool_output",
"tool_name": "python",
"tool_call_id": "call_1",
"text": f"line {i}\n",
}
yield {
"type": "tool_end",
"tool_name": "python",
"tool_call_id": "call_1",
"result": "r2",
}
yield {"type": "content", "text": "final answer"}
return gen()
async def _drive():
async def _is_disconnected():
return False
request = SimpleNamespace(is_disconnected = _is_disconnected)
resp = await _anthropic_tool_stream(
request,
_threading.Event(),
run_gen,
"msg_drop_ka2",
"m",
disable_parallel_tool_use = True,
)
return [chunk async for chunk in resp.body_iterator]
chunks = asyncio.run(_drive())
keepalives = [c for c in chunks if c == _OPENAI_PASSTHROUGH_SSE_KEEPALIVE]
assert len(keepalives) == n_output
# The dropped call must not surface as a second tool_use block.
tool_use_starts = [c for c in chunks if "content_block_start" in c and '"tool_use"' in c]
assert len(tool_use_starts) == 1
assert any("final answer" in c for c in chunks)
def test_plain_stream_emits_keepalive_during_prompt_stall(monkeypatch):
"""No-tool Anthropic stream must emit SSE keepalives while a long prompt
prefill blocks next(gen), matching the tool stream (finding 5). The old
single unbounded to_thread(next, ...) could sit silent past a proxy idle cap."""
import threading as _threading
import time as _time
from routes import inference as inf_mod
from routes.inference import _OPENAI_PASSTHROUGH_SSE_KEEPALIVE, _anthropic_plain_stream
monkeypatch.setattr(inf_mod, "_LOCAL_TOOL_STREAM_STALL_KEEPALIVE_S", 0.05)
def run_gen():
def gen():
_time.sleep(0.24) # stall past several shortened keepalive windows
yield "hello world"
return gen()
async def _drive():
async def _is_disconnected():
return False
request = SimpleNamespace(is_disconnected = _is_disconnected)
resp = await _anthropic_plain_stream(
request, _threading.Event(), run_gen, "msg_plain_ka", "m"
)
return [chunk async for chunk in resp.body_iterator]
chunks = asyncio.run(_drive())
keepalives = [c for c in chunks if c == _OPENAI_PASSTHROUGH_SSE_KEEPALIVE]
assert len(keepalives) >= 2
assert any("hello world" in c for c in chunks)
def test_plain_stream_closes_generator_on_disconnect():
"""On disconnect the no-tool teardown must drain any pending worker and close
the generator (finding 6). The old finally only stopped the disconnect
watcher, leaking the generator. A fake generator records close() so the
teardown is asserted deterministically, not via GC."""
import threading as _threading
from routes.inference import _anthropic_plain_stream
closed = _threading.Event()
class _FakeGen:
def __init__(self):
self._items = iter(["tok0", "tok1", "tok2", "tok3"])
def __next__(self):
return next(self._items)
def close(self):
closed.set()
def run_gen():
return _FakeGen()
state = {"disconnected": False}
async def _drive():
async def _is_disconnected():
return state["disconnected"]
request = SimpleNamespace(is_disconnected = _is_disconnected)
resp = await _anthropic_plain_stream(
request, _threading.Event(), run_gen, "msg_plain_close", "m"
)
out = []
async for chunk in resp.body_iterator:
out.append(chunk)
if "tok0" in chunk:
# Client drops after the first token; the next loop turn tears down.
state["disconnected"] = True
return out
asyncio.run(_drive())
assert closed.is_set()

View file

@ -0,0 +1,371 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""System-directory denylist enforcement for the folder browser.
Once the allowlist can hold a whole Windows drive root (C:\\) or a legacy /
root, the browse endpoints must re-apply the ``_denied_path_prefixes()`` policy
``add_scan_folder`` enforces, so /etc, /proc, C:\\Windows, C:\\Program Files stay
unbrowseable even under an allowlisted root. Windows/macOS branches run on this
POSIX host by AST-extracting the pure helper with ``ntpath`` / a mocked ``platform``.
"""
from __future__ import annotations
import ast
import ntpath
import os
import posixpath
from pathlib import Path
from types import SimpleNamespace
from typing import Optional
import pytest
from hub.storage import scan_folders
from storage import studio_db
from utils.paths.external_media import is_local_filesystem_root
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
class _HTTPException(Exception):
def __init__(self, status_code: int, detail: str):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
def _extract_is_denied_windows():
"""is_denied_system_path (+ _denied_path_prefixes) from studio_db.py under Windows semantics (ntpath) on a POSIX host."""
src = (_BACKEND_ROOT / "storage" / "studio_db.py").read_text(encoding = "utf-8")
tree = ast.parse(src)
funcs = [
n
for n in tree.body
if isinstance(n, ast.FunctionDef)
and n.name in {"_denied_path_prefixes", "is_denied_system_path"}
]
module = ast.Module(body = funcs, type_ignores = [])
ast.fix_missing_locations(module)
win_os = SimpleNamespace(
sep = "\\",
environ = {
"SystemRoot": r"C:\Windows",
"ProgramFiles": r"C:\Program Files",
"ProgramFiles(x86)": r"C:\Program Files (x86)",
},
path = SimpleNamespace(normcase = ntpath.normcase),
)
ns = {
"os": win_os,
"platform": SimpleNamespace(system = lambda: "Windows"),
# /run has no Windows analog, so the carve-out is never reached.
"is_linux_run_media_path": lambda _p: False,
}
exec(compile(module, "<extracted studio_db.py>", "exec"), ns)
return ns["is_denied_system_path"]
# is_denied_system_path -- Linux (real helper, this host)
@pytest.mark.parametrize(
"path",
[
"/etc",
"/etc/ssl/private",
"/proc",
"/proc/1",
"/sys",
"/dev",
"/boot",
"/run",
"/run/systemd/private",
"/run/media",
"/run/media/dspofu",
],
)
def test_is_denied_system_path_linux_denies_system_dirs(monkeypatch, path):
monkeypatch.setattr(studio_db.platform, "system", lambda: "Linux")
assert studio_db.is_denied_system_path(path) is True
@pytest.mark.parametrize(
"path",
["/run/media/dspofu/nvmeB", "/run/media/dspofu/nvmeB/models"],
)
def test_is_denied_system_path_linux_allows_run_media_mounts(monkeypatch, path):
# The /run/media/<user>/<volume> carve-out keeps removable media browseable.
monkeypatch.setattr(studio_db.platform, "system", lambda: "Linux")
assert studio_db.is_denied_system_path(path) is False
@pytest.mark.parametrize(
"path",
["/etc-backup", "/etcetera", "/home/u/models", "/mnt/data", "/devices", "/", "/opt/models"],
)
def test_is_denied_system_path_linux_allows_non_system(monkeypatch, path):
monkeypatch.setattr(studio_db.platform, "system", lambda: "Linux")
assert studio_db.is_denied_system_path(path) is False
def test_legacy_and_hub_denylist_agree(monkeypatch):
monkeypatch.setattr(studio_db.platform, "system", lambda: "Linux")
monkeypatch.setattr(scan_folders.platform, "system", lambda: "Linux")
for p in ["/etc", "/proc/1", "/home/u", "/boot", "/opt/x"]:
assert studio_db.is_denied_system_path(p) == scan_folders.is_denied_system_path(p)
# is_denied_system_path -- Windows (ntpath-backed), case-insensitive + collisions
@pytest.mark.parametrize(
"path",
[
r"C:\Windows",
r"C:\Windows\System32",
r"c:\windows",
r"C:\WINDOWS\Temp",
r"C:\Program Files",
r"C:\Program Files\x",
r"C:\Program Files (x86)\y",
r"c:\program files",
],
)
def test_is_denied_system_path_windows_denies_system_dirs(path):
is_denied = _extract_is_denied_windows()
assert is_denied(path) is True
@pytest.mark.parametrize(
"path",
[
r"C:\Models",
r"D:\models",
r"C:\WindowsApps",
r"C:\ProgramData",
r"C:\Program Files Extra",
r"E:\gguf",
r"C:\Users\me\models",
],
)
def test_is_denied_system_path_windows_allows_non_system(path):
is_denied = _extract_is_denied_windows()
assert is_denied(path) is False
# _resolve_browse_target -- real-FS integration (legacy browser)
def _extract_resolver():
"""Extract the legacy browse resolver; its inline imports use the real storage.studio_db policy."""
src = (_BACKEND_ROOT / "routes" / "models.py").read_text(encoding = "utf-8")
tree = ast.parse(src)
names = {
"_is_path_inside_allowlist",
"_normalize_browse_request_path",
"_browse_relative_parts",
"_match_browse_child",
"_resolve_browse_target",
}
funcs = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in names]
module = ast.Module(body = funcs, type_ignores = [])
ast.fix_missing_locations(module)
ns = {
"os": os,
"Path": Path,
"Optional": Optional,
"HTTPException": _HTTPException,
"logger": SimpleNamespace(warning = lambda *a, **k: None, debug = lambda *a, **k: None),
}
exec(compile(module, "<extracted routes/models.py>", "exec"), ns)
return ns["_resolve_browse_target"]
def test_resolve_browse_target_blocks_etc_via_root():
# Registering "/" must not make /etc browsable (Codex #3 regression guard).
resolve = _extract_resolver()
with pytest.raises(_HTTPException) as exc:
resolve("/etc", [Path("/")])
assert exc.value.status_code == 403
def test_resolve_browse_target_blocks_stale_denied_root(tmp_path, monkeypatch):
# A stale scan-folder row pointing at a denied dir is refused by the
# browse-time denylist even though it is its own allowlist root. A tmp-based
# denied prefix (+ Linux compare) keeps the assertion OS-agnostic: on macOS
# tmp lives under the already-denied /private/var, masking the message.
denied = (tmp_path / "sysfake").resolve()
denied.mkdir()
monkeypatch.setattr(studio_db.platform, "system", lambda: "Linux")
monkeypatch.setattr(studio_db, "_denied_path_prefixes", lambda: [str(denied)])
resolve = _extract_resolver()
with pytest.raises(_HTTPException) as exc:
resolve(str(denied), [denied])
assert exc.value.status_code == 403
assert "System directories" in exc.value.detail
def test_resolve_browse_target_allows_root_itself():
resolve = _extract_resolver()
assert resolve("/", [Path("/")]) == Path("/")
def test_resolve_browse_target_allows_legit_nested_dir(tmp_path, monkeypatch):
# Force the Linux denylist so the macOS temp location (under the denied
# /private/var) doesn't reject the tmp fixture; a normal nested dir must not be over-blocked.
monkeypatch.setattr(studio_db.platform, "system", lambda: "Linux")
resolve = _extract_resolver()
base = tmp_path / "allowed"
sub = base / "models" / "gguf"
sub.mkdir(parents = True)
assert resolve(str(sub), [base]) == sub.resolve()
def test_resolve_browse_target_symlink_escape_blocked(tmp_path):
resolve = _extract_resolver()
base = tmp_path / "allowed"
base.mkdir()
link = base / "escape"
try:
link.symlink_to("/etc", target_is_directory = True)
except OSError:
pytest.skip("symlinks unsupported on this host")
with pytest.raises(_HTTPException) as exc:
resolve(str(link), [base])
assert exc.value.status_code == 403
# _is_path_inside_allowlist -- bare POSIX root parity (legacy == hub)
def _extract_is_inside(rel_parts, *, os_module = os):
"""Extract a standalone _is_path_inside_allowlist (os/Path only) so both browsers' copies compare without importing their heavy modules."""
src = _BACKEND_ROOT.joinpath(*rel_parts).read_text(encoding = "utf-8")
tree = ast.parse(src)
funcs = [
n
for n in tree.body
if isinstance(n, ast.FunctionDef) and n.name == "_is_path_inside_allowlist"
]
module = ast.Module(body = funcs, type_ignores = [])
ast.fix_missing_locations(module)
ns = {"os": os_module, "Path": Path}
exec(compile(module, f"<extracted {'/'.join(rel_parts)}>", "exec"), ns)
return ns["_is_path_inside_allowlist"]
# ntpath semantics with a no-FS realpath, so UNC containment can be driven on a
# POSIX CI (the real realpath cannot resolve \\server\share off Windows).
_WIN_OS = SimpleNamespace(
sep = ntpath.sep,
path = SimpleNamespace(
realpath = lambda p: ntpath.normpath(str(p)),
normcase = ntpath.normcase,
splitdrive = ntpath.splitdrive,
dirname = ntpath.dirname,
commonpath = ntpath.commonpath,
),
)
def test_legacy_and_hub_allowlist_agree_on_posix_root():
# A bare "/" allowlist entry must authorize only "/" itself in BOTH
# browsers, never descend into /var, /root, /home (which the denylist does
# not cover). Guards the hub browser against authorizing every absolute path.
legacy = _extract_is_inside(["routes", "models.py"])
hub = _extract_is_inside(["hub", "services", "models", "folder_browser.py"])
roots = [Path("/")]
for tgt in ["/var", "/root", "/home", "/usr", "/opt", "/etc"]:
assert legacy(Path(tgt), roots) is False
assert hub(Path(tgt), roots) is False
# "/" itself stays browseable; only its descendants are withheld.
assert legacy(Path("/"), roots) is True
assert hub(Path("/"), roots) is True
def test_hub_allowlist_authorizes_normal_nested_dir(tmp_path):
# The bare-root special case must not over-block a normal allowlist root's descendants.
hub = _extract_is_inside(["hub", "services", "models", "folder_browser.py"])
base = tmp_path / "allowed"
sub = base / "models" / "gguf"
sub.mkdir(parents = True)
assert hub(sub, [base]) is True
assert hub(base, [base]) is True
# add_scan_folder -- filesystem-root rejection parity (legacy == hub)
def test_legacy_add_scan_folder_rejects_filesystem_root(monkeypatch):
monkeypatch.setattr(studio_db.platform, "system", lambda: "Linux")
with pytest.raises(ValueError, match = "filesystem root"):
studio_db.add_scan_folder("/")
def test_hub_add_scan_folder_rejects_filesystem_root(monkeypatch):
monkeypatch.setattr(scan_folders.platform, "system", lambda: "Linux")
with pytest.raises(ValueError, match = "filesystem root"):
scan_folders.add_scan_folder("/")
# is_local_filesystem_root: reject "/" and "C:\\" (roots above denied system dirs),
# but NOT a UNC share root -- registering \\server\share was allowed before this
# guard and has no system dirs under it. _pathmod drives Windows semantics on POSIX CI.
@pytest.mark.parametrize(
"path, pathmod, expected",
[
# Local filesystem roots -> rejected (True).
("/", posixpath, True),
("C:\\", ntpath, True),
("c:\\", ntpath, True),
("D:\\", ntpath, True),
# UNC share roots -> NOT a local root, stay registerable (False).
(r"\\server\share", ntpath, False),
(r"\\nas\models", ntpath, False),
("//server/share", ntpath, False),
# Device / extended-length volume roots -> still local roots (rejected),
# so neither \\?\C:\ nor a drive-letter-less \\?\Volume{GUID}\ can slip
# past the guard as if it were a share root.
(r"\\?\C:" + "\\", ntpath, True),
(r"\\.\C:" + "\\", ntpath, True),
(r"\\?\C:", ntpath, True),
(r"\\.\C:", ntpath, True),
(r"\\?\Volume{2f8e6d31-0000-0000-0000-100000000000}" + "\\", ntpath, True),
(r"\\.\Volume{2f8e6d31-0000-0000-0000-100000000000}", ntpath, True),
# Device-namespace UNC share root -> stays registerable (False).
(r"\\?\UNC\server\share", ntpath, False),
# Non-root paths (incl. deep device / extended-length) -> not a root (False).
("C:\\Models", ntpath, False),
(r"\\server\share\models", ntpath, False),
(r"\\?\C:\Users\me\models", ntpath, False),
(r"\\?\Volume{2f8e6d31-0000-0000-0000-100000000000}\models", ntpath, False),
("/home/user", posixpath, False),
],
)
def test_is_local_filesystem_root(path, pathmod, expected):
assert is_local_filesystem_root(path, _pathmod = pathmod) is expected
def test_both_guards_use_the_shared_local_root_helper():
# Register-root parity: both browsers reject the same roots via one helper, so a
# UNC-share exemption can never drift between the legacy and hub code paths.
legacy_src = (_BACKEND_ROOT / "storage" / "studio_db.py").read_text(encoding = "utf-8")
hub_src = (_BACKEND_ROOT / "hub" / "storage" / "scan_folders.py").read_text(encoding = "utf-8")
assert "is_local_filesystem_root(normalized)" in legacy_src
assert "is_local_filesystem_root(normalized)" in hub_src
# A registered UNC share root must authorize its own descendants in both browsers.
# os.path.commonpath raises "can't mix absolute and relative" on a bare
# \\server\share, so containment falls back to a boundary-safe prefix test; without
# it, registering a UNC share (now allowed) would 403 every folder under it.
@pytest.mark.parametrize(
"rel_parts",
[
["routes", "models.py"],
["hub", "services", "models", "folder_browser.py"],
],
)
def test_unc_share_root_authorizes_its_descendants(rel_parts):
is_inside = _extract_is_inside(rel_parts, os_module = _WIN_OS)
root = [Path(r"\\server\share")]
assert is_inside(Path(r"\\server\share"), root) is True # the root itself
assert is_inside(Path(r"\\server\share\models"), root) is True # direct child
assert is_inside(Path(r"\\server\share\a\b\c"), root) is True # deep descendant
assert is_inside(Path(r"\\SERVER\SHARE\Models"), root) is True # case-insensitive
assert is_inside(Path(r"\\server\share2\models"), root) is False # sibling share
assert is_inside(Path(r"C:\models"), root) is False # different volume

View file

@ -22,6 +22,18 @@ if "structlog" not in sys.modules:
)
import routes.models as models_route
import storage.studio_db as studio_db
@pytest.fixture(autouse = True)
def _denylist_inert(monkeypatch):
# These tests exercise allowlist containment and the file-vs-directory guard,
# not the system-directory denylist (which has its own suite in
# test_browse_denylist.py). On macOS tmp_path resolves under /private/var, a
# denied prefix, so _resolve_browse_target would 403 the fixture dirs before
# the containment logic runs. Keep the denylist inert here so these
# assertions hold on every platform.
monkeypatch.setattr(studio_db, "is_denied_system_path", lambda _p: False)
def test_resolve_browse_target_returns_allowed_directory(tmp_path):

View file

@ -13,6 +13,7 @@ never gating under bypass.
Run with: ``PYTHONPATH=studio/backend python -m pytest studio/backend/tests/test_bypass_permissions.py -q``
"""
import io
import os
import sys
@ -94,10 +95,23 @@ def test_safe_env_excludes_host_and_secret(monkeypatch, tmp_path):
class _FakeProc:
returncode = 0
"""A subprocess.Popen double for the drain path (``tools._drain_process_output``):
a readable ``stdout`` pipe yielding the fake output then EOF, plus
``wait()`` / ``poll()`` / ``pid``. The pid is non-existent so
``_capture_process_group``'s ``os.getpgid`` returns None; ``wait`` returns
immediately so the drain never kills.
"""
def communicate(self, timeout = None):
return ("FAKEOUT", None)
returncode = 0
# Unlikely-to-exist pid: os.getpgid raises ProcessLookupError (caught) -> None.
pid = 2**22
def __init__(self):
# Readable stdout: iter(readline, "") yields "FAKEOUT" then hits EOF.
self.stdout = io.StringIO("FAKEOUT")
def wait(self, timeout = None):
return 0
def poll(self):
return 0
@ -257,6 +271,7 @@ def test_loop_forwards_disable_sandbox_and_does_not_gate():
cancel_event = None,
timeout = None,
session_id = None,
thread_id = None,
rag_scope = None,
disable_sandbox = False,
):
@ -290,6 +305,7 @@ def test_loop_bypass_overrides_confirm_for_direct_callers():
cancel_event = None,
timeout = None,
session_id = None,
thread_id = None,
rag_scope = None,
disable_sandbox = False,
):

View file

@ -651,11 +651,19 @@ class TestLoadModelGuardIntegration(unittest.TestCase):
inf._shutdown_subprocess = MagicMock()
llama = SimpleNamespace(is_loaded = False, model_identifier = None, hf_variant = None)
llama.unload_model = MagicMock()
cfg = SimpleNamespace(is_gguf = False, is_lora = False, path = None, base_model = None)
cfg = SimpleNamespace(
is_gguf = False,
is_lora = False,
path = None,
base_model = None,
identifier = "unsloth/Qwen3-1.7B",
)
request = LoadRequest(model_path = "unsloth/Qwen3-1.7B")
info = {"required_gb": 40.0, "usable_gb": 5.0, "needed_gb": 50.0, "mode": "auto"}
with (
# Pin the latest-sidecar tier check so the guard path stays offline.
patch("utils.transformers_version.latest_tier_active_for", return_value = False),
patch.object(self.route, "validate_extra_args", return_value = None),
patch.object(
self.route,

View file

@ -691,13 +691,14 @@ def _argparse_default(source, option):
return None
def test_run_server_cloudflare_default_true():
def test_run_server_cloudflare_default_off():
defaults = _func_param_defaults(_RUN_PY.read_text(), "run_server")
assert defaults.get("cloudflare") is True
assert "cloudflare" in defaults
assert defaults["cloudflare"] is None
def test_argparse_cloudflare_default_true():
assert _argparse_default(_RUN_PY.read_text(), "--cloudflare") is True
def test_argparse_cloudflare_default_off():
assert _argparse_default(_RUN_PY.read_text(), "--cloudflare") is None
def test_verify_global_reachability_marks_private_address_unreachable():
@ -832,6 +833,31 @@ def test_cloudflare_line_states_disabled_when_off(monkeypatch):
assert "local network only" in out
def test_cloudflare_line_labels_unset_as_default(monkeypatch):
# None = off by default (no flag) -> banner says "(default)", not "(--no-cloudflare)".
out = _run_print_cloudflare_line(
monkeypatch,
cloudflare_url = None,
public_reachable = False,
cloudflare_requested = False,
cloudflare_flag = None,
)
assert "Cloudflare tunnel: OFF (default)" in out
assert "--no-cloudflare" not in out
def test_cloudflare_line_labels_explicit_no_cloudflare(monkeypatch):
# False = explicit --no-cloudflare -> banner says "(--no-cloudflare)".
out = _run_print_cloudflare_line(
monkeypatch,
cloudflare_url = None,
public_reachable = False,
cloudflare_requested = False,
cloudflare_flag = False,
)
assert "Cloudflare tunnel: OFF (--no-cloudflare)" in out
def test_cloudflare_line_states_failed_when_requested_but_no_url(monkeypatch):
out = _run_print_cloudflare_line(
monkeypatch,

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

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

@ -287,7 +287,9 @@ def test_degrades_when_shared_helper_import_raises_importerror():
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."""
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
@ -321,11 +323,15 @@ def test_retries_under_light_gpu_init_when_import_fails(monkeypatch):
sys.meta_path.insert(0, finder)
try:
degraded = importlib.import_module("utils.hf_xet_fallback")
# First attempt without the light env, then a retry with it set.
# 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(degraded.DownloadStallError, RuntimeError)
# The env override must not leak past the import.
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)
@ -333,3 +339,31 @@ def test_retries_under_light_gpu_init_when_import_fails(monkeypatch):
sys.modules.update(saved)
if saved_shim is not None:
sys.modules["utils.hf_xet_fallback"] = saved_shim
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)
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,123 @@
# 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 coverage for bootstrap password exposure to remote clients."""
from types import SimpleNamespace
def _request(
client_host,
request_host = "127.0.0.1",
headers = None,
):
"""Build a minimal request; ``None`` models an unresolved peer / absent Host."""
client = None if client_host is None else SimpleNamespace(host = client_host, port = 0)
hdrs = {}
if request_host is not None:
hdrs["host"] = request_host
hdrs.update(headers or {})
return SimpleNamespace(client = client, headers = hdrs, url = SimpleNamespace(hostname = request_host))
def test_loopback_peers_are_local():
from main import _is_local_bootstrap_request
cases = (
("127.0.0.1", "127.0.0.1"),
("::1", "::1"),
("::ffff:127.0.0.1", "::ffff:127.0.0.1"),
("127.0.0.1", "localhost"),
)
for peer, host in cases:
assert _is_local_bootstrap_request(_request(peer, host)) is True, (peer, host)
def test_non_loopback_peers_are_remote():
from main import _is_local_bootstrap_request
# ::1%eth0 is a scope-id'd address, which ipaddress treats as loopback on
# 3.9+; it must not count as a direct local peer.
for host in ("192.168.1.10", "::ffff:192.168.1.10", "::1%eth0"):
assert _is_local_bootstrap_request(_request(host)) is False, host
def test_absent_or_unparseable_peer_fails_safe():
from main import _is_local_bootstrap_request
for host in (None, "localhost"):
assert _is_local_bootstrap_request(_request(host)) is False, host
def test_cloudflare_tunnel_clients_are_remote_despite_loopback_peer():
from main import _is_local_bootstrap_request
for client_ip in ("203.0.113.7", ""):
request = _request("127.0.0.1", headers = {"cf-connecting-ip": client_ip})
assert _is_local_bootstrap_request(request) is False, client_ip
def test_dns_rebinding_host_is_remote_despite_loopback_peer():
from main import _is_local_bootstrap_request
for host in ("attacker.example", "192.168.1.10", None):
assert _is_local_bootstrap_request(_request("127.0.0.1", host)) is False, host
def test_unparseable_request_host_fails_safe():
"""A Host that makes ``request.url.hostname`` raise must fall to remote."""
from main import _is_local_bootstrap_request
class _RaisingURL:
@property
def hostname(self):
raise ValueError("malformed host")
request = SimpleNamespace(
client = SimpleNamespace(host = "127.0.0.1", port = 0), headers = {}, url = _RaisingURL()
)
assert _is_local_bootstrap_request(request) is False
def test_reverse_proxy_forwarded_headers_are_remote():
"""A loopback proxy relaying a remote client (non-Cloudflare headers) is remote."""
from main import _is_local_bootstrap_request
for header in ("forwarded", "x-forwarded-for", "x-forwarded-host", "x-real-ip"):
request = _request("127.0.0.1", "localhost", headers = {header: "203.0.113.7"})
assert _is_local_bootstrap_request(request) is False, header
def test_malformed_or_absent_host_is_remote():
"""A malformed/absent/scope-id Host must not fall back to the loopback server address."""
from main import _is_local_bootstrap_request
# incl. bracket smuggling: [::1]evil / unclosed [::1 must not reduce to ::1
for host in (
"e_vil",
"[malformed",
"",
None,
"[::1%25eth0]:8888",
"[::1]attacker",
"[::1]evil.com",
"[::1",
"[::1]x",
):
assert _is_local_bootstrap_request(_request("127.0.0.1", host)) is False, host
def test_colab_allows_notebook_proxy_but_not_shareable_tunnel(monkeypatch):
"""Colab autofills its single-user proxy, but not a public Cloudflare link."""
import main
monkeypatch.setattr(main, "_IS_COLAB", True)
# In-notebook proxy: same-origin, no tunnel header, injects off-loopback too.
assert main._should_inject_bootstrap(_request("10.0.0.2", "colab.proxy")) is True
# Shareable Cloudflare link marks visitors with cf-connecting-ip; withhold.
tunnel = _request("127.0.0.1", "localhost", headers = {"cf-connecting-ip": "203.0.113.7"})
assert main._should_inject_bootstrap(tunnel) is False
def test_non_colab_gate_requires_local_client(monkeypatch):
"""Outside Colab the gate injects only for a direct loopback client."""
import main
monkeypatch.setattr(main, "_IS_COLAB", False)
assert main._should_inject_bootstrap(_request("127.0.0.1", "localhost")) is True
assert main._should_inject_bootstrap(_request("192.168.1.10", "localhost")) is False

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

@ -252,10 +252,17 @@ def test_legacy_browse_allowlist_includes_linux_run_media_mounts(monkeypatch, tm
outputs_root = lambda: tmp_path / "missing-outputs",
exports_root = lambda: tmp_path / "missing-exports",
)
fake_external_media = SimpleNamespace(linux_run_media_mount_roots = lambda: [media_root])
fake_external_media = SimpleNamespace(
linux_run_media_mount_roots = lambda: [media_root],
windows_drive_roots = lambda: [],
)
fake_studio_db = SimpleNamespace(
list_scan_folders = lambda: [],
contains_sensitive_path_component = studio_db.contains_sensitive_path_component,
# The media root is a legitimate mount, not denied; the .ssh 403 below
# comes from the credential check. A False stub keeps this OS-independent
# (on macOS tmp_path lives under the denied /private/var).
is_denied_system_path = lambda _p: False,
)
monkeypatch.setitem(sys.modules, "utils.paths", fake_paths)
monkeypatch.setitem(sys.modules, "utils.paths.external_media", fake_external_media)

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,192 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
import contextlib
import os
import socket
import sys
import threading
import time
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 == []
class _StallUpstream:
"""Raw HTTP/1.1 server that streams one chunked SSE chunk, then holds the
socket open and silent so the client's next read blocks in recv() until its
side is torn down. Reproduces a mid-stream stall (llama-server goes quiet)."""
def __init__(self):
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._sock.bind(("127.0.0.1", 0))
self._sock.listen(1)
self.port = self._sock.getsockname()[1]
self._stop = threading.Event()
self._thread = threading.Thread(target = self._serve, daemon = True)
@property
def url(self) -> str:
return f"http://127.0.0.1:{self.port}/v1/chat/completions"
def __enter__(self):
self._thread.start()
return self
def __exit__(self, *_exc):
self._stop.set()
try:
self._sock.close()
except OSError:
pass
self._thread.join(timeout = 5)
def _serve(self) -> None:
try:
conn, _ = self._sock.accept()
except OSError:
return
with conn:
conn.settimeout(5)
try:
buf = b""
while b"\r\n\r\n" not in buf:
data = conn.recv(4096)
if not data:
return
buf += data
head, _, body = buf.partition(b"\r\n\r\n")
content_length = 0
for line in head.split(b"\r\n"):
if line.lower().startswith(b"content-length:"):
content_length = int(line.split(b":", 1)[1].strip())
break
while len(body) < content_length:
data = conn.recv(4096)
if not data:
break
body += data
except OSError:
return
conn.sendall(
b"HTTP/1.1 200 OK\r\n"
b"Content-Type: text/event-stream\r\n"
b"Transfer-Encoding: chunked\r\n"
b"\r\n"
)
chunk = b"data: hello\n\n"
conn.sendall(b"%x\r\n%s\r\n" % (len(chunk), chunk))
# Stall: stay open and silent until the client shuts its side down.
while not self._stop.wait(timeout = 0.05):
try:
conn.settimeout(0.05)
if conn.recv(1) == b"":
return
except socket.timeout:
continue
except OSError:
return
def test_cancel_interrupts_a_read_blocked_on_a_mid_stream_stall():
# Mid-stream stall: the reader is parked in recv() on a long bound read timeout,
# so response.close() alone can't wake it; the watcher must shut the socket down.
# Assert cancel lands in seconds, not at the far-off deadline (pre-fix: hung ~30s).
with _StallUpstream() as server:
cancel_event = threading.Event()
def _cancel_soon():
time.sleep(0.3)
cancel_event.set()
threading.Thread(target = _cancel_soon, daemon = True).start()
started = time.monotonic()
with httpx.Client(
limits = httpx.Limits(max_keepalive_connections = 0), trust_env = False
) as client:
with pytest.raises(_LlamaStreamCancelled):
with LlamaCppBackend._stream_with_retry(
client,
server.url,
{},
cancel_event,
first_token_deadline = started + 30,
) as response:
for _chunk in response.iter_text():
pass # first chunk arrives, then the read blocks silently
elapsed = time.monotonic() - started
assert elapsed < 10, f"cancel took {elapsed:.1f}s; the blocked read was not interrupted"

View file

@ -1498,6 +1498,59 @@ def test_textual_mistral_marker_not_leaked_when_inline_with_preface(monkeypatch)
assert any("Let me search." in t for t in content_texts)
def test_textual_explicit_id_reuses_provisional_card(monkeypatch):
# A textual Mistral-style call with an explicit ``id`` must reconcile onto the
# open provisional TEXT card (keyed "call_0"), not spawn a duplicate under the
# explicit id (which the parser keeps for execution).
big_query = "cats " * 80 # push the drained call past the provisional floor
call = "[TOOL_CALLS]" + json.dumps(
[{"name": "web_search", "arguments": {"query": big_query}, "id": "explicit-42"}]
)
assert len(call) > 256
# Small chunks so the provisional card opens mid-generation (a single-shot
# delta parses instantly and never shows a provisional to exercise).
chunks = [call[i : i + 24] for i in range(0, len(call), 24)]
streams = [
[_sse({"content": c}) for c in chunks] + [_done()],
[_sse({"content": "done"}), _done()],
]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, streams, payloads)
calls: list[tuple[str, dict]] = []
def fake_execute_tool(name, arguments, **_kwargs):
calls.append((name, arguments))
return "result"
monkeypatch.setattr("core.inference.tools.execute_tool", fake_execute_tool)
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "search"}],
tools = [{"type": "function", "function": {"name": "web_search"}}],
max_tool_iterations = 1,
)
)
assert calls == [("web_search", {"query": big_query})]
tool_starts = [e for e in events if e.get("type") == "tool_start"]
# Empty-args card = provisional open; full-args card = reconciled real start.
provisional = [e for e in tool_starts if not e.get("arguments")]
real = [e for e in tool_starts if e.get("arguments", {}).get("query")]
assert len(provisional) == 1, tool_starts # provisional actually opened
prov_id = provisional[0]["tool_call_id"]
# Exactly one real card, sharing the provisional id, not a duplicate under
# the explicit "explicit-42" id.
assert len(real) == 1, tool_starts
assert real[0]["tool_call_id"] == prov_id
assert real[0]["tool_name"] == "web_search"
assert {e["tool_call_id"] for e in tool_starts} == {prov_id}
# A single tool_end reconciles the card; no stale empty-result close.
ends = [e for e in events if e.get("type") == "tool_end"]
assert [e["tool_call_id"] for e in ends] == [prov_id]
assert ends[0]["result"] == "result"
def test_textual_llama_python_tag_marker_not_leaked(monkeypatch):
# Same leak class for the Llama-3 built-in ``<|python_tag|>NAME.call(...)`` form.
streams = [
@ -1826,6 +1879,39 @@ def test_large_python_tool_call_emits_early_provisional_start(monkeypatch):
assert any(e.get("type") == "tool_end" and e.get("tool_name") == "python" for e in events)
def test_auto_mode_render_html_suppresses_provisional_card_under_confirm(monkeypatch):
"""render_html is no longer unconditionally safe (a networked canvas asks), so
with confirm_tool_calls set under permission_mode="auto" its early provisional
card is suppressed; the real full-argument tool_start still fires and a static
canvas runs without a prompt."""
args = {"code": "<html>" + "x" * 80 + "</html>"}
first_stream = _streamed_structured_tool_call("render_html", args, "call_rh")
final_stream = [_sse({"content": "Done."}), _done()]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [first_stream, final_stream], payloads)
monkeypatch.setattr("core.inference.tools.execute_tool", lambda name, arguments, **_k: "OK")
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "make a card"}],
tools = [{"type": "function", "function": {"name": "render_html"}}],
confirm_tool_calls = True,
permission_mode = "auto",
max_tool_iterations = 1,
)
)
tool_starts = [e for e in events if e.get("type") == "tool_start"]
provisional = [e for e in tool_starts if not e.get("arguments")]
# The confirm gate now suppresses the early provisional card for render_html.
assert provisional == [], tool_starts
real = [e for e in tool_starts if e.get("arguments")]
assert real and real[0]["tool_name"] == "render_html"
# A static canvas is classified safe, so it still runs without an approval gate.
assert real[0].get("awaiting_confirmation") in (False, None)
def test_small_python_tool_call_has_no_provisional_start(monkeypatch):
"""A small tool-call argument finishes streaming instantly, so it keeps the
existing behavior of a single (real) tool_start with no provisional card."""
@ -2883,3 +2969,204 @@ def test_gguf_valid_tool_calls_respect_max_tool_iterations(monkeypatch):
m.get("role") == "user" and "used all available tool calls" in m.get("content", "")
for m in payloads[2]["messages"]
), payloads[2]["messages"]
# ── Live tool-call argument streaming (tool_args events) ─────────────────────
def _python_tool_schema() -> list[dict]:
return [
{
"type": "function",
"function": {
"name": "python",
"description": "Run python code.",
"parameters": {
"type": "object",
"properties": {"code": {"type": "string"}},
"required": ["code"],
},
},
}
]
def test_structured_tool_args_stream_to_provisional_card(monkeypatch):
"""A large structured tool call must stream its arguments as tool_args events
to the provisional card (backlog that triggered the card, then each
fragment), while the executed call and the model's view stay exactly what the
accumulator built."""
code = "print('x')\n" + ("# pad\n" * 80)
args_json = json.dumps({"code": code})
call_id = "call_live_args"
split = _PROVISIONAL_ARGS_MIN_CHARS + 16
frag1, frag2, frag3 = (
args_json[:split],
args_json[split : split + 40],
args_json[split + 40 :],
)
def _tc_delta(fragment: str, with_header: bool) -> str:
entry: dict = {"index": 0, "function": {"arguments": fragment}}
if with_header:
entry.update({"id": call_id, "type": "function"})
entry["function"]["name"] = "python"
return _sse({"tool_calls": [entry]})
first_stream = [
_tc_delta(frag1, with_header = True),
_tc_delta(frag2, with_header = False),
_tc_delta(frag3, with_header = False),
_done(),
]
second_stream = [_sse({"content": "Done."}), _done()]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [first_stream, second_stream], payloads)
executed: list[tuple[str, dict]] = []
def fake_execute_tool(name, arguments, **_kwargs):
executed.append((name, arguments))
return "ok"
monkeypatch.setattr("core.inference.tools.execute_tool", fake_execute_tool)
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "run it"}],
tools = _python_tool_schema(),
max_tool_iterations = 1,
)
)
starts = [e for e in events if e.get("type") == "tool_start"]
assert starts and starts[0]["tool_call_id"] == call_id
args_events = [e for e in events if e.get("type") == "tool_args"]
assert args_events, "no tool_args events were streamed"
assert all(e["tool_call_id"] == call_id for e in args_events)
# First event is the backlog, the rest raw fragments; together the args JSON.
assert args_events[0]["text"] == frag1
assert "".join(e["text"] for e in args_events) == args_json
# The streamed display path must not perturb execution or the model view.
assert executed == [("python", {"code": code})]
assistant_messages = [m for m in payloads[1]["messages"] if m.get("role") == "assistant"]
tc = assistant_messages[-1]["tool_calls"][0]
assert tc["id"] == call_id
# Controller re-serializes args (normalized JSON); parsed payload unchanged.
assert json.loads(tc["function"]["arguments"]) == {"code": code}
def test_text_tool_call_streams_args_and_reconciles_card(monkeypatch):
"""A TEXT (XML) tool call must stream its raw call text as tool_args under the
id the stream-end parser assigns ("call_0"), so the provisional card and the
final tool_start reconcile."""
code = "print('hello')\n" + ("# filler\n" * 60)
call_json = json.dumps({"name": "python", "arguments": {"code": code}})
call_text = f"<tool_call>{call_json}</tool_call>"
chunks = [call_text[i : i + 48] for i in range(0, len(call_text), 48)]
first_stream = [_sse({"content": chunk}) for chunk in chunks] + [_done()]
second_stream = [_sse({"content": "Done."}), _done()]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [first_stream, second_stream], payloads)
executed: list[tuple[str, dict]] = []
def fake_execute_tool(name, arguments, **_kwargs):
executed.append((name, arguments))
return "ok"
monkeypatch.setattr("core.inference.tools.execute_tool", fake_execute_tool)
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "run it"}],
tools = _python_tool_schema(),
max_tool_iterations = 1,
)
)
starts = [e for e in events if e.get("type") == "tool_start"]
assert starts, "no tool_start emitted"
# Provisional card first (parser's first-call id), then the reconciling start.
assert starts[0]["tool_call_id"] == "call_0"
assert starts[0]["arguments"] == {}
assert starts[-1]["tool_call_id"] == "call_0"
args_events = [e for e in events if e.get("type") == "tool_args"]
assert args_events, "no tool_args events for the text call"
assert all(e["tool_call_id"] == "call_0" for e in args_events)
streamed = "".join(e["text"] for e in args_events)
# Streamed text is the drained call (display only); it must never leak into
# content events.
assert '"name": "python"' in streamed
assert executed == [("python", {"code": code})]
content_events = [e for e in events if e.get("type") == "content"]
assert not any("<tool_call>" in e["text"] for e in content_events)
def test_ordinary_json_answer_streams_no_tool_args(monkeypatch):
"""A large ordinary JSON answer (no enabled tool name) must not spawn a
provisional card or tool_args events; it stays a normal content answer."""
answer = json.dumps({"result": "fine", "data": ["x" * 40] * 12, "note": "not a tool call"})
chunks = [answer[i : i + 64] for i in range(0, len(answer), 64)]
stream = [_sse({"content": chunk}) for chunk in chunks] + [_done()]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [stream], payloads)
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "give me json"}],
tools = _python_tool_schema(),
max_tool_iterations = 1,
)
)
assert not [e for e in events if e.get("type") == "tool_args"]
assert not [e for e in events if e.get("type") == "tool_start"]
content_events = [e for e in events if e.get("type") == "content"]
assert content_events and answer in content_events[-1]["text"]
def test_provisional_text_card_closed_when_parse_fails(monkeypatch):
"""A >=256-char enabled-name text sniff opens a provisional card; if the
drained text then fails to parse (auto-heal off, truncated call), the
DRAINING false-positive path must close the card with a tool_end instead of
leaving it spinning forever."""
# Truncated mid-arguments and never closed: unparseable without healing.
call_text = '<tool_call>{"name": "python", "arguments": {"code": "' + "x" * (
_PROVISIONAL_ARGS_MIN_CHARS + 64
)
chunks = [call_text[i : i + 48] for i in range(0, len(call_text), 48)]
stream = [_sse({"content": chunk}) for chunk in chunks] + [_done()]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [stream], payloads)
executed: list[tuple[str, dict]] = []
def fake_execute_tool(name, arguments, **_kwargs):
executed.append((name, arguments))
return "ok"
monkeypatch.setattr("core.inference.tools.execute_tool", fake_execute_tool)
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "run it"}],
tools = _python_tool_schema(),
max_tool_iterations = 1,
auto_heal_tool_calls = False,
)
)
starts = [e for e in events if e.get("type") == "tool_start"]
ends = [e for e in events if e.get("type") == "tool_end"]
assert starts and starts[0]["tool_call_id"] == "call_0"
assert executed == [] # nothing parsed, nothing ran
assert ends, "provisional card left dangling (no tool_end)"
assert ends[-1]["tool_call_id"] == "call_0"

View file

@ -393,6 +393,9 @@ def test_start_update_source_build_installs_prebuilt(monkeypatch, tmp_path):
assert "--llama-tag" in cmd and "latest" in cmd
assert cmd[cmd.index("--rocm-gfx") + 1] == "gfx110x"
assert "--simple-policy" not in cmd and "--cpu-fallback" not in cmd
# No pin: source-build detection and the unpinned apply share the same
# "latest" resolver, so they already agree.
assert "--published-release-tag" not in cmd
def test_start_update_happy_path(monkeypatch, tmp_path):
@ -448,6 +451,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")
@ -477,6 +522,57 @@ def test_start_update_reports_full_release_tag(monkeypatch, tmp_path):
assert "Updated llama.cpp to b9596-mix-e6f2453." in job["message"]
def _run_start_update_to_completion():
res = upd.start_update()
assert res["started"] is True
deadline = time.time() + 10
while time.time() < deadline:
job = upd.get_update_status()["job"]
if job["state"] in ("success", "error"):
return job
time.sleep(0.05)
return upd.get_update_status()["job"]
def test_start_update_pinned_tag_mismatch_fails(monkeypatch, tmp_path):
# Installer stays on the pinned repo but produces a different tag -> it
# ignored the pin (the silent mismatch this pin exists to prevent). Fail loud.
monkeypatch.setattr(sys, "platform", "linux")
install_dir = tmp_path / "llama.cpp"
binary = _write_install(install_dir, "b9595")
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: "b9601-mix-a0e2906"
)
_patch_installer_popen(
monkeypatch,
on_start = lambda cmd: _write_install(install_dir, "b9500", release_tag = "b9500-mix-deadbee"),
)
job = _run_start_update_to_completion()
assert job["state"] == "error", job
assert "b9601-mix-a0e2906" in (job["error"] or "")
def test_start_update_pinned_reroute_to_other_repo_ok(monkeypatch, tmp_path):
# A Vulkan/Intel host reroutes fork->upstream and drops the pin, installing a
# different-repo tag. Legitimate: the pin check must not flag the repo switch.
monkeypatch.setattr(sys, "platform", "linux")
install_dir = tmp_path / "llama.cpp"
binary = _write_install(install_dir, "b9595", repo = "unslothai/llama.cpp")
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: "b9601-mix-a0e2906"
)
_patch_installer_popen(
monkeypatch,
on_start = lambda cmd: _write_install(install_dir, "b9601", repo = "ggml-org/llama.cpp"),
)
job = _run_start_update_to_completion()
assert job["state"] == "success", job
def test_start_update_installer_failure_reports_error(monkeypatch, tmp_path):
install_dir = tmp_path / "llama.cpp"
binary = _write_install(install_dir, "b9493")
@ -594,9 +690,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,
@ -620,6 +717,33 @@ def test_install_cmd_cuda_marker_minimal_and_backward_compatible(monkeypatch, tm
assert "--cpu-fallback" not in cmd
def test_install_cmd_pins_offered_release_tag(monkeypatch, tmp_path):
# Apply must install exactly the release the banner offered. The installer's
# own "latest" comes from commit-date-ordered sources, which can lag the
# published_at-newest tag detection picked; unpinned, that lag makes Update
# reinstall the current build while the banner never clears.
monkeypatch.setattr(sys, "platform", "linux")
cmd = _capture_install_cmd(monkeypatch, tmp_path, latest = "b9601-mix-a0e2906")
# The full release identity is pinned, not the bare upstream base.
assert cmd[cmd.index("--published-release-tag") + 1] == "b9601-mix-a0e2906"
def test_install_cmd_pins_on_windows(monkeypatch, tmp_path):
# The darwin exemption must not leak to other platforms.
monkeypatch.setattr(sys, "platform", "win32")
cmd = _capture_install_cmd(monkeypatch, tmp_path)
assert cmd[cmd.index("--published-release-tag") + 1] == "b9518"
def test_install_cmd_does_not_pin_on_macos(monkeypatch, tmp_path):
# A pinned tag disables the installer's older-release walk-back, which macOS
# needs to skip prebuilts built for a newer macOS than the host.
monkeypatch.setattr(sys, "platform", "darwin")
cmd = _capture_install_cmd(monkeypatch, tmp_path)
assert "--published-release-tag" not in cmd
assert "--llama-tag" in cmd and "latest" in cmd
# --- refusal + maintenance-state coordination ---

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

@ -0,0 +1,48 @@
# 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 /api/inference/load-progress throttle: one line per 10% step, reset per load."""
import pytest
import routes.inference as ri
class _Capture:
def __init__(self):
self.events = []
def info(self, event, **kw):
self.events.append((event, kw))
@pytest.fixture
def cap(monkeypatch):
capture = _Capture()
monkeypatch.setattr(ri, "logger", capture)
ri._reset_load_progress_step()
return capture
def _percents(cap):
return [kw["percent"] for _event, kw in cap.events]
def test_new_load_first_step_logs_after_reset(cap):
# Load A reaches 100%.
ri._log_load_progress_step(1.0, "ready")
assert _percents(cap) == [100]
# Same value keeps deduping (steady poll on a finished load stays quiet).
ri._log_load_progress_step(1.0, "ready")
assert _percents(cap) == [100]
# A new load arms the throttle, so a cached load B that reports 100% on its
# first poll still emits its progress line instead of hitting step == prev.
ri._reset_load_progress_step()
ri._log_load_progress_step(1.0, "ready")
assert _percents(cap) == [100, 100]
def test_steady_poll_dedups_within_a_load(cap):
for _ in range(3):
ri._log_load_progress_step(0.3, "mmap")
assert _percents(cap) == [30] # one line per 10% step, not one per poll

View file

@ -135,7 +135,7 @@ def test_duplicate_get_within_window_deduped(logs, monkeypatch):
mw = LoggingMiddleware(app)
for _ in range(3):
_run(mw(_http_scope("/api/chat/projects"), _noop_receive, send))
_run(mw(_http_scope("/api/models/browse-folders"), _noop_receive, send))
# Only the first of the identical GET/200 burst is logged.
assert len(logs.events) == 1
@ -183,11 +183,11 @@ def test_quiet_poll_paths_use_longer_heartbeat_window(logs, monkeypatch):
for _ in range(3):
_run(mw(_http_scope("/api/inference/monitor"), _noop_receive, send)) # quiet
for _ in range(3):
_run(mw(_http_scope("/api/chat/projects"), _noop_receive, send)) # normal
_run(mw(_http_scope("/api/models/browse-folders"), _noop_receive, send)) # normal
paths = [e[2]["path"] for e in logs.events]
assert paths.count("/api/inference/monitor") == 1 # collapsed to one heartbeat
assert paths.count("/api/chat/projects") == 3 # base dedup off -> all logged
assert paths.count("/api/models/browse-folders") == 3 # base dedup off -> all logged
def test_distinct_query_strings_are_not_deduped(logs, monkeypatch):
@ -242,3 +242,118 @@ def test_fastapi_static_asset_success_skips_log(tmp_path, logs):
assert response.status_code == 200
assert response.text == "body { color: black; }"
assert len(logs.events) == log_count
def _status_app(status):
async def app(scope, receive, send):
await send({"type": "http.response.start", "status": status, "headers": []})
await send({"type": "http.response.body", "body": b""})
return app
async def _drop(message):
pass
def _paths_logged(logs):
return [e[2]["path"] for e in logs.events]
def test_quiet_success_get_2xx_suppressed(logs):
# A GET/2xx poll on a quiet-success path logs nothing; the signal is in events.
for path in ("/api/chat/threads", "/api/export/status", "/api/hub/download-status"):
_run(LoggingMiddleware(_status_app(200))(_http_scope(path), _noop_receive, _drop))
assert logs.events == []
def test_chat_detail_and_message_reads_still_log(logs):
# Only the exact list polls are suppressed; detail/message reads carry latency
# signal and keep their access line.
for path in (
"/api/chat/threads/abc123",
"/api/chat/threads/abc123/messages",
"/api/chat/threads/abc123/messages/m1",
"/api/chat/projects/p1",
):
_run(LoggingMiddleware(_status_app(200))(_http_scope(path), _noop_receive, _drop))
assert _paths_logged(logs) == [
"/api/chat/threads/abc123",
"/api/chat/threads/abc123/messages",
"/api/chat/threads/abc123/messages/m1",
"/api/chat/projects/p1",
]
def test_quiet_success_is_get_only(logs):
# Mutations on the same paths still log (suppression is GET-only).
for method in ("POST", "PUT", "DELETE"):
_run(
LoggingMiddleware(_status_app(200))(
_http_scope("/api/chat/threads", method = method), _noop_receive, _drop
)
)
assert len(logs.events) == 3
def test_chat_pre_auth_401_suppressed_other_errors_logged(logs):
# The transient bootstrap 401 on a chat list GET is dropped, but a 500 (or any
# other status) still logs so real failures stay visible.
_run(
LoggingMiddleware(_status_app(401))(_http_scope("/api/chat/projects"), _noop_receive, _drop)
)
assert logs.events == []
_run(
LoggingMiddleware(_status_app(500))(_http_scope("/api/chat/projects"), _noop_receive, _drop)
)
assert _paths_logged(logs) == ["/api/chat/projects"]
def test_chat_401_logged_after_first_auth_refresh(logs):
# A chat 401 before any successful token refresh is the bootstrap race and is
# dropped, but once /api/auth/refresh has succeeded on this instance later chat
# 401s are real failures and stay visible.
responses: dict[tuple[str, str], int] = {}
async def app(scope, receive, send):
status = responses.get((scope["method"], scope["path"]), 200)
await send({"type": "http.response.start", "status": status, "headers": []})
await send({"type": "http.response.body", "body": b""})
mw = LoggingMiddleware(app)
responses[("GET", "/api/chat/threads")] = 401
_run(mw(_http_scope("/api/chat/threads"), _noop_receive, _drop))
assert logs.events == [] # bootstrap race: suppressed
# A successful refresh (POST, always logged) closes the bootstrap window.
responses[("POST", "/api/auth/refresh")] = 200
_run(mw(_http_scope("/api/auth/refresh", method = "POST"), _noop_receive, _drop))
assert _paths_logged(logs) == ["/api/auth/refresh"]
# Now the same chat 401 is a real failure and logs.
_run(mw(_http_scope("/api/chat/threads"), _noop_receive, _drop))
assert _paths_logged(logs) == ["/api/auth/refresh", "/api/chat/threads"]
def test_export_status_error_still_logs(logs):
# 2xx suppressed, but an HTTP-level error on export status remains visible.
_run(
LoggingMiddleware(_status_app(200))(_http_scope("/api/export/status"), _noop_receive, _drop)
)
assert logs.events == []
_run(
LoggingMiddleware(_status_app(500))(_http_scope("/api/export/status"), _noop_receive, _drop)
)
assert _paths_logged(logs) == ["/api/export/status"]
def test_legacy_download_progress_heartbeats_not_suppressed(logs, monkeypatch):
# Legacy /api/models download polls emit no progress events, so they heartbeat
# (first hit logs, the burst collapses) rather than vanish entirely.
monkeypatch.setattr(hmod, "_ACCESS_LOG_DEDUP_MS", 0)
monkeypatch.setattr(hmod, "_QUIET_POLL_DEDUP_MS", 1000)
mw = LoggingMiddleware(_status_app(200))
for _ in range(3):
_run(mw(_http_scope("/api/models/download-progress"), _noop_receive, _drop))
assert _paths_logged(logs) == ["/api/models/download-progress"]

View file

@ -0,0 +1,177 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from __future__ import annotations
import contextlib
import json
import sys
from pathlib import Path
from types import SimpleNamespace
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
from core.inference import mcp_client
from core.inference.mcp_client import (
MAX_IMAGE_PAYLOAD_CHARS,
MCP_IMAGES_SENTINEL,
_flatten_result,
call_tool_sync,
)
from core.inference.tool_loop_controller import is_tool_error, strip_result_for_model
PNG_B64 = "iVBORw0KGgoAAAANSUhEUg=="
def _text(value: str) -> SimpleNamespace:
return SimpleNamespace(type = "text", text = value)
def _image(data: str = PNG_B64, mime: str = "image/png") -> SimpleNamespace:
return SimpleNamespace(type = "image", data = data, mimeType = mime)
def _result(
*blocks,
is_error = False,
structured = None,
) -> SimpleNamespace:
return SimpleNamespace(
content = list(blocks),
is_error = is_error,
structured_content = structured,
)
def test_text_only_result_unchanged():
assert _flatten_result(_result(_text("hello"))) == "hello"
def test_image_only_result_keeps_image_and_notes_model():
flat = _flatten_result(_result(_image()))
body, payload = flat.split("\n" + MCP_IMAGES_SENTINEL, 1)
assert body == "[1 image attached; displayed to the user]"
assert json.loads(payload) == [{"data": PNG_B64, "mimeType": "image/png"}]
def test_text_plus_image_keeps_both():
flat = _flatten_result(_result(_text("Took a screenshot"), _image()))
body, payload = flat.split("\n" + MCP_IMAGES_SENTINEL, 1)
assert body == "Took a screenshot\n[1 image attached; displayed to the user]"
assert json.loads(payload)[0]["mimeType"] == "image/png"
def test_multiple_images_pluralized():
flat = _flatten_result(_result(_image(), _image(mime = "image/jpeg")))
body, payload = flat.split("\n" + MCP_IMAGES_SENTINEL, 1)
assert "[2 images attached; displayed to the user]" in body
assert [img["mimeType"] for img in json.loads(payload)] == ["image/png", "image/jpeg"]
def test_strip_result_for_model_drops_image_payload():
flat = _flatten_result(_result(_text("Took a screenshot"), _image()))
stripped = strip_result_for_model(flat)
assert stripped == "Took a screenshot\n[1 image attached; displayed to the user]"
assert PNG_B64 not in stripped
def test_strip_preserves_literal_mcp_sentinel_in_text():
# A tool that legitimately returns text containing the marker (e.g. reading
# source/docs that quote it) must not be truncated: the suffix is not a
# valid JSON image array.
text = "before\n__MCP_IMAGES__: literal from source\nafter"
assert strip_result_for_model(text) == text
def test_strip_preserves_non_image_json_after_marker():
text = 'log line\n__MCP_IMAGES__:["not", "image", "dicts"]'
assert strip_result_for_model(text) == text
def test_strip_removes_only_valid_terminal_envelope():
text = (
"Earlier mention: __MCP_IMAGES__: is documented here"
"\n[1 image attached; displayed to the user]"
'\n__MCP_IMAGES__:[{"data": "AAAA", "mimeType": "image/png"}]'
)
assert strip_result_for_model(text) == (
"Earlier mention: __MCP_IMAGES__: is documented here"
"\n[1 image attached; displayed to the user]"
)
def test_strip_still_handles_images_and_rag_sentinels():
assert strip_result_for_model("output\n__IMAGES__:['a.png']") == "output"
assert strip_result_for_model("answer\n__RAG_SOURCES__:[{}]") == "answer"
def test_error_result_keeps_error_prefix_and_images():
flat = _flatten_result(_result(_text("boom"), _image(), is_error = True))
assert flat.startswith("Error: boom")
assert is_tool_error(flat)
assert MCP_IMAGES_SENTINEL in flat
def test_image_only_error_no_longer_reports_no_content():
flat = _flatten_result(_result(_image(), is_error = True))
assert flat.startswith("Error: [1 image attached")
assert "tool returned no content" not in flat
def test_oversized_image_omitted_with_note():
huge = "A" * (MAX_IMAGE_PAYLOAD_CHARS + 1)
flat = _flatten_result(_result(_image(data = huge)))
assert flat == "[1 image omitted (too large)]"
assert MCP_IMAGES_SENTINEL not in flat
def test_oversized_budget_shared_across_images():
big = "A" * (MAX_IMAGE_PAYLOAD_CHARS - 10)
flat = _flatten_result(_result(_image(data = big), _image()))
body, payload = flat.split("\n" + MCP_IMAGES_SENTINEL, 1)
assert "1 image attached" in body
assert "1 image omitted (too large)" in body
images = json.loads(payload)
assert len(images) == 1 and images[0]["data"] == big
def test_non_image_binary_block_still_ignored():
flat = _flatten_result(
_result(SimpleNamespace(type = "audio", data = PNG_B64, mimeType = "audio/wav"))
)
assert flat == ""
def test_structured_content_fallback_still_used():
flat = _flatten_result(_result(structured = {"ok": True}))
assert flat == "{'ok': True}"
def test_call_tool_sync_passes_raise_on_error_false_and_keeps_error_images(monkeypatch):
# Guards that call_tool_sync passes raise_on_error=False, so an is_error result
# with image content reaches _flatten_result instead of FastMCP raising ToolError.
seen = {}
class _FakeClient:
async def call_tool(
self,
name,
args,
raise_on_error = True,
):
seen["raise_on_error"] = raise_on_error
return _result(_text("boom"), _image(), is_error = True)
@contextlib.asynccontextmanager
async def _fake_client(url, headers, use_oauth):
yield _FakeClient()
monkeypatch.setattr(mcp_client, "_client", _fake_client)
out = call_tool_sync("http://x", None, "take_screenshot", {})
assert seen["raise_on_error"] is False
assert out.startswith("Error: boom")
assert MCP_IMAGES_SENTINEL in out
assert is_tool_error(out)

View file

@ -198,7 +198,12 @@ def test_call_tool_sync_respects_pre_set_cancel_event(monkeypatch):
async def __aexit__(self, *args):
return False
async def call_tool(self, name, args):
async def call_tool(
self,
name,
args,
raise_on_error = True,
):
import asyncio as _asyncio
await _asyncio.sleep(30) # never finishes during the test
@ -520,7 +525,12 @@ def test_call_tool_sync_short_circuits_on_pre_set_cancel(monkeypatch):
async def __aexit__(self, *args):
return False
async def call_tool(self, name, args):
async def call_tool(
self,
name,
args,
raise_on_error = True,
):
return "ran"
monkeypatch.setattr(mcp_client, "_client", lambda *a, **kw: _StubClient())
@ -787,6 +797,68 @@ def test_update_display_name_keeps_tool_cache(tmp_path, monkeypatch):
assert mcp_client.get_cached_tools("s1") == cached
def test_update_rename_keeps_stdio_session(tmp_path, monkeypatch):
"""The edit dialog resends url/headers/oauth unchanged on a rename, so gating
the close on field presence would drop the live stdio session. Only a real
endpoint/auth change may close it."""
import asyncio
import json
_reset_db(tmp_path, monkeypatch)
from models.mcp_servers import McpServerUpdate
import routes.mcp_servers as routes_mcp
closed: list = []
monkeypatch.setattr(routes_mcp, "stdio_mcp_enabled", lambda: True)
monkeypatch.setattr(routes_mcp, "close_stdio_sessions", lambda *a, **k: closed.append(a))
mcp_servers_db.create_server(
id = "s1",
display_name = "A",
url = "npx demo-server",
headers_json = json.dumps({"API_KEY": "x"}),
is_enabled = True,
)
asyncio.run(
routes_mcp.update_mcp_server(
"s1",
McpServerUpdate(
display_name = "B",
url = "npx demo-server",
headers = {"API_KEY": "x"},
use_oauth = False,
),
current_subject = "u",
)
)
assert closed == []
assert mcp_servers_db.get_server("s1")["display_name"] == "B"
def test_update_stdio_command_change_closes_session(tmp_path, monkeypatch):
"""A real command change must still close the old stdio session."""
import asyncio
_reset_db(tmp_path, monkeypatch)
from models.mcp_servers import McpServerUpdate
import routes.mcp_servers as routes_mcp
closed: list = []
monkeypatch.setattr(routes_mcp, "stdio_mcp_enabled", lambda: True)
monkeypatch.setattr(routes_mcp, "close_stdio_sessions", lambda *a, **k: closed.append(a))
mcp_servers_db.create_server(
id = "s1",
display_name = "A",
url = "npx demo-server",
is_enabled = True,
)
asyncio.run(
routes_mcp.update_mcp_server(
"s1", McpServerUpdate(url = "npx other-server"), current_subject = "u"
)
)
assert len(closed) == 1
def test_update_disable_evicts_tool_cache(tmp_path, monkeypatch):
"""Disabling a server must drop its cached tools, not leave them unread."""
import asyncio

View file

@ -93,7 +93,12 @@ class _RecordingClient:
async def list_tools(self):
return [_FakeTool("list_directory"), _FakeTool("write_file")]
async def call_tool(self, name, args):
async def call_tool(
self,
name,
args,
raise_on_error = True,
):
return _FakeResult(f"called {name}")

View file

@ -0,0 +1,629 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from __future__ import annotations
import asyncio
import sys
import threading
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
from core.inference import mcp_client
from core.inference.mcp_client import call_tool_sync, close_stdio_sessions
STDIO_URL = "npx fake-stateful-server"
HTTP_URL = "https://mcp.example.test/mcp"
def _result(text: str) -> SimpleNamespace:
return SimpleNamespace(
content = [SimpleNamespace(type = "text", text = text)],
is_error = False,
structured_content = None,
)
class FakeClient:
instances: list["FakeClient"] = []
def __init__(self, url: str):
self.url = url
self.entered = 0
self.exited = 0
self.calls: list[tuple[str, dict]] = []
self.connected = False
self.fail_next = False
self.call_delay = 0.0
# Models a dead stdio transport: real Client.is_connected() stays True
# after the subprocess dies, so liveness is probed via the transport.
self.dead = False
self.transport = SimpleNamespace(_is_session_dead = lambda: self.dead)
FakeClient.instances.append(self)
async def __aenter__(self):
self.entered += 1
self.connected = True
return self
async def __aexit__(self, *exc):
self.exited += 1
self.connected = False
def is_connected(self) -> bool:
return self.connected
async def call_tool(self, name: str, args: dict):
if self.call_delay:
await asyncio.sleep(self.call_delay)
if self.fail_next:
self.fail_next = False
self.connected = False
raise RuntimeError("transport closed")
self.calls.append((name, args))
return _result(f"call-{len(self.calls)}")
@pytest.fixture
def fake_clients(monkeypatch):
FakeClient.instances = []
monkeypatch.setattr(
mcp_client, "_client", lambda url, headers, use_oauth = False: FakeClient(url)
)
yield FakeClient.instances
close_stdio_sessions()
def test_stdio_call_without_scope_is_one_shot(fake_clients):
r1 = call_tool_sync(STDIO_URL, None, "browser_navigate", {"url": "https://x.test"})
r2 = call_tool_sync(STDIO_URL, None, "browser_take_screenshot", {})
assert r1 == "call-1"
assert r2 == "call-1"
assert len(fake_clients) == 2
assert all(client.entered == 1 and client.exited == 1 for client in fake_clients)
def test_stdio_sessions_keyed_by_url_and_env(fake_clients):
call_tool_sync(STDIO_URL, None, "t", {})
call_tool_sync("npx other-server", None, "t", {})
call_tool_sync(STDIO_URL, {"ENV_VAR": "1"}, "t", {})
assert len(fake_clients) == 3
def test_stdio_sessions_scoped_per_chat(fake_clients):
# Two conversations must not share one stateful server process.
call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat-a")
call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat-b")
call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat-a")
assert len(fake_clients) == 2
assert fake_clients[0].calls and len(fake_clients[0].calls) == 2
def test_dead_stdio_session_recovers(fake_clients):
assert call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat") == "call-1"
# Subprocess dies between calls: the dead transport is detected before the
# next dispatch, so the call reconnects on a fresh session instead of failing.
fake_clients[0].dead = True
assert call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat") == "call-1"
assert len(fake_clients) == 2
assert fake_clients[0].exited == 1
def test_tool_error_does_not_recycle_session(fake_clients, monkeypatch):
from fastmcp.exceptions import ToolError
class ToolFailure(FakeClient):
async def call_tool(self, name, args):
if name == "boom":
raise ToolError("tool exploded") # tool-level: session stays connected
return await super().call_tool(name, args)
monkeypatch.setattr(
mcp_client, "_client", lambda url, headers, use_oauth = False: ToolFailure(url)
)
assert call_tool_sync(STDIO_URL, None, "boom", {}, scope = "chat").startswith("Error: MCP tool")
assert call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat") == "call-1"
assert len(fake_clients) == 1
def test_http_stays_one_shot(fake_clients):
call_tool_sync(HTTP_URL, None, "t", {})
call_tool_sync(HTTP_URL, None, "t", {})
assert len(fake_clients) == 2
assert all(c.entered == 1 and c.exited == 1 for c in fake_clients)
def test_timeout_discards_stdio_session(fake_clients):
call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat")
key = mcp_client._session_key(STDIO_URL, None, "chat")
fake_clients[0].call_delay = 0.5
out = call_tool_sync(
STDIO_URL,
None,
"slow",
{},
timeout = 0.05,
cancel_event = threading.Event(),
scope = "chat",
)
assert "timed out" in out
assert fake_clients[0].exited == 1
assert key not in mcp_client._stdio_key_locks
assert call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat") == "call-1"
assert len(fake_clients) == 2
def test_no_timeout_allows_long_call(fake_clients):
call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat")
fake_clients[0].call_delay = 0.2
# timeout=None means no deadline: the call must not be treated as wedged.
assert call_tool_sync(STDIO_URL, None, "slow", {}, timeout = None, scope = "chat") == "call-2"
def test_connect_races_cancel_event(fake_clients, monkeypatch):
class SlowStart(FakeClient):
async def __aenter__(self):
await asyncio.sleep(5.0)
return await super().__aenter__()
monkeypatch.setattr(mcp_client, "_client", lambda url, headers, use_oauth = False: SlowStart(url))
ev = threading.Event()
threading.Timer(0.1, ev.set).start()
start = time.monotonic()
out = call_tool_sync(STDIO_URL, None, "t", {}, cancel_event = ev)
assert out == "Error: MCP tool 't' cancelled"
assert time.monotonic() - start < 3.0
assert mcp_client._stdio_sessions == {}
def test_connect_respects_caller_timeout(fake_clients, monkeypatch):
class SlowStart(FakeClient):
async def __aenter__(self):
await asyncio.sleep(5.0)
return await super().__aenter__()
monkeypatch.setattr(mcp_client, "_client", lambda url, headers, use_oauth = False: SlowStart(url))
start = time.monotonic()
out = call_tool_sync(STDIO_URL, None, "t", {}, timeout = 0.2)
assert "timed out" in out
assert time.monotonic() - start < 3.0
assert mcp_client._stdio_sessions == {}
def test_connect_failure_timeout_surfaces_immediately(fake_clients, monkeypatch):
class InitTimeout(FakeClient):
async def __aenter__(self):
raise asyncio.TimeoutError # e.g. fastmcp's own init timeout
monkeypatch.setattr(
mcp_client, "_client", lambda url, headers, use_oauth = False: InitTimeout(url)
)
start = time.monotonic()
out = call_tool_sync(STDIO_URL, None, "t", {}, timeout = 30.0)
assert "timed out" in out
# Must fail fast, not wait out the 30s/60s connect window.
assert time.monotonic() - start < 5.0
assert mcp_client._stdio_sessions == {}
def test_key_lock_wait_honors_cancel_and_timeout(fake_clients, monkeypatch):
class SlowStart(FakeClient):
async def __aenter__(self):
await asyncio.sleep(1.5)
return await super().__aenter__()
monkeypatch.setattr(mcp_client, "_client", lambda url, headers, use_oauth = False: SlowStart(url))
first = threading.Thread(target = lambda: call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat"))
first.start()
key = mcp_client._session_key(STDIO_URL, None, "chat")
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
key_lock = mcp_client._stdio_key_locks.get(key)
if key_lock is not None and key_lock.lock.locked():
break
time.sleep(0.01)
# Second same-scope call is stuck behind the first slow connect: Stop must
# interrupt the key-lock wait, and a short tool timeout must bound it.
ev = threading.Event()
threading.Timer(0.2, ev.set).start()
start = time.monotonic()
out = call_tool_sync(STDIO_URL, None, "t", {}, cancel_event = ev, scope = "chat")
assert out == "Error: MCP tool 't' cancelled"
assert time.monotonic() - start < 1.0
start = time.monotonic()
out = call_tool_sync(STDIO_URL, None, "t", {}, timeout = 0.2, scope = "chat")
assert "timed out" in out
assert time.monotonic() - start < 1.0
first.join(10.0)
assert not first.is_alive()
def test_cancel_pre_set_spawns_nothing(fake_clients):
ev = threading.Event()
ev.set()
out = call_tool_sync(STDIO_URL, None, "t", {}, cancel_event = ev)
assert out == "Error: MCP tool 't' cancelled"
assert fake_clients == []
def test_idle_reap_closes_session(fake_clients, monkeypatch):
call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat")
key = mcp_client._session_key(STDIO_URL, None, "chat")
assert key in mcp_client._stdio_key_locks
monkeypatch.setattr(mcp_client, "_STDIO_SESSION_IDLE_TTL", 0.0)
mcp_client._reap_idle_stdio_sessions()
assert fake_clients[0].exited == 1
assert mcp_client._stdio_sessions == {}
assert key not in mcp_client._stdio_key_locks
# Next call transparently opens a fresh session.
assert call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat") == "call-1"
assert len(fake_clients) == 2
def test_reap_skips_in_flight_session(fake_clients, monkeypatch):
call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat")
monkeypatch.setattr(mcp_client, "_STDIO_SESSION_IDLE_TTL", 0.0)
session = next(iter(mcp_client._stdio_sessions.values()))
with mcp_client._stdio_sessions_lock:
session.in_flight = 1
try:
mcp_client._reap_idle_stdio_sessions()
assert fake_clients[0].exited == 0
finally:
with mcp_client._stdio_sessions_lock:
session.in_flight = 0
def test_close_during_connect_is_not_cached(fake_clients, monkeypatch):
class SlowStart(FakeClient):
async def __aenter__(self):
await asyncio.sleep(0.5)
return await super().__aenter__()
monkeypatch.setattr(mcp_client, "_client", lambda url, headers, use_oauth = False: SlowStart(url))
results: list[str] = []
worker = threading.Thread(
target = lambda: results.append(call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat"))
)
worker.start()
deadline = time.monotonic() + 5.0
while not fake_clients and time.monotonic() < deadline:
time.sleep(0.01)
assert fake_clients # connect is in progress
# Server deleted/updated mid-connect: the session must not be cached after.
close_stdio_sessions(STDIO_URL)
worker.join(10.0)
assert results and results[0].startswith("Error: MCP tool 't' failed")
assert mcp_client._stdio_sessions == {}
assert fake_clients[0].exited == 1
def test_connect_abort_race_still_closes_client(fake_clients, monkeypatch):
class WinsRace(FakeClient):
async def __aenter__(self):
try:
await asyncio.sleep(5.0)
except asyncio.CancelledError:
pass # connect finishes just as the abort lands
return await super().__aenter__()
monkeypatch.setattr(mcp_client, "_client", lambda url, headers, use_oauth = False: WinsRace(url))
out = call_tool_sync(STDIO_URL, None, "t", {}, timeout = 0.1)
assert "timed out" in out
assert fake_clients[0].entered == 1
assert fake_clients[0].exited == 1 # no orphaned subprocess
assert mcp_client._stdio_sessions == {}
def test_close_unblocks_no_limit_call(fake_clients):
call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat")
session = next(iter(mcp_client._stdio_sessions.values()))
fake_clients[0].call_delay = 30.0
results: list[str] = []
worker = threading.Thread(
target = lambda: results.append(
call_tool_sync(STDIO_URL, None, "slow", {}, timeout = None, scope = "chat")
)
)
worker.start()
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
with mcp_client._stdio_sessions_lock:
if session.in_flight >= 1:
break
time.sleep(0.01)
# Server deleted while a no-limit call is in flight: the request thread
# must not hang forever on the stopped session loop.
close_stdio_sessions(STDIO_URL)
worker.join(5.0)
assert not worker.is_alive()
assert results and results[0].startswith("Error: MCP tool 'slow' failed")
def test_lock_wait_timeout_spares_the_borrowed_session(fake_clients):
call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat")
session = next(iter(mcp_client._stdio_sessions.values()))
fake_clients[0].call_delay = 1.0
results: list[str] = []
slow = threading.Thread(
target = lambda: results.append(
call_tool_sync(STDIO_URL, None, "slow", {}, timeout = None, scope = "chat")
)
)
slow.start()
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
with mcp_client._stdio_sessions_lock:
if session.in_flight >= 1:
break
time.sleep(0.01)
# A second same-scope call times out waiting for the call lock; it never
# touched the transport, so the shared session must stay alive and cached.
out = call_tool_sync(STDIO_URL, None, "fast", {}, timeout = 0.05, scope = "chat")
assert "timed out" in out
assert fake_clients[0].exited == 0
slow.join(10.0)
assert results == ["call-2"]
assert fake_clients[0].exited == 0
assert len(mcp_client._stdio_sessions) == 1
def test_stale_session_close_deferred_until_borrower_drains(fake_clients):
call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat")
session = next(iter(mcp_client._stdio_sessions.values()))
fake_clients[0].call_delay = 0.8
results: list[str] = []
slow = threading.Thread(
target = lambda: results.append(
call_tool_sync(STDIO_URL, None, "slow", {}, timeout = None, scope = "chat")
)
)
slow.start()
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
with mcp_client._stdio_sessions_lock:
if session.in_flight >= 1:
break
time.sleep(0.01)
# The subprocess "dies" mid-call: a new caller replaces the stale session,
# but its close must wait for the slow borrower instead of killing its call.
fake_clients[0].connected = False
out = call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat")
assert out == "call-1"
assert len(fake_clients) == 2
assert fake_clients[0].exited == 0
slow.join(10.0)
assert results == ["call-2"]
assert fake_clients[0].exited == 1 # last borrower performed the deferred close
assert len(mcp_client._stdio_sessions) == 1
def test_error_on_closed_session_does_not_retry(fake_clients):
call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat")
session = next(iter(mcp_client._stdio_sessions.values()))
# A close can surface at the borrower as a plain transport error instead
# of _SessionClosed; that must not be treated as a crash and retried.
fake_clients[0].fail_next = True
session.closed.set()
out = call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat")
assert out == "Error: MCP tool 't' failed: MCP server was updated or removed during the call"
assert len(fake_clients) == 1 # no respawn for the removed config
def test_config_check_blocks_stale_publish(fake_clients):
# Simulates a caller that read the server row before an update/delete:
# the row re-check runs after connect and must block caching.
out = call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat", config_check = lambda: False)
assert out.startswith("Error: MCP tool 't' failed")
assert mcp_client._stdio_sessions == {}
assert fake_clients[0].exited == 1
def test_close_generation_keys_hold_no_secrets(fake_clients):
secret_url = "npx server --token sk-url-secret"
close_stdio_sessions(secret_url, {"API_KEY": "sk-env-secret"})
close_stdio_sessions(secret_url)
gen_keys = list(mcp_client._stdio_cfg_close_gen) + list(mcp_client._stdio_url_close_gen)
assert gen_keys
# These maps are never pruned: neither command/URL nor env may persist.
assert all("sk-url-secret" not in repr(k) and "sk-env-secret" not in repr(k) for k in gen_keys)
def test_overlapping_calls_serialize_on_shared_session(fake_clients, monkeypatch):
class OverlapDetect(FakeClient):
active = 0
max_active = 0
async def call_tool(self, name, args):
OverlapDetect.active += 1
OverlapDetect.max_active = max(OverlapDetect.max_active, OverlapDetect.active)
try:
await asyncio.sleep(0.2)
return await super().call_tool(name, args)
finally:
OverlapDetect.active -= 1
monkeypatch.setattr(
mcp_client, "_client", lambda url, headers, use_oauth = False: OverlapDetect(url)
)
call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat")
workers = [
threading.Thread(target = lambda: call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat"))
for _ in range(2)
]
for worker in workers:
worker.start()
for worker in workers:
worker.join(10.0)
# A stateful server must never see interleaved same-scope operations.
assert OverlapDetect.max_active == 1
assert len(fake_clients) == 1
def test_timeout_budget_spans_connect_and_call(fake_clients, monkeypatch):
class SlowBoth(FakeClient):
async def __aenter__(self):
await asyncio.sleep(0.4)
return await super().__aenter__()
async def call_tool(self, name, args):
await asyncio.sleep(0.5)
return await super().call_tool(name, args)
monkeypatch.setattr(mcp_client, "_client", lambda url, headers, use_oauth = False: SlowBoth(url))
start = time.monotonic()
# 0.4s connect + 0.5s call vs a 0.6s budget: the call must inherit only
# the remaining ~0.2s, not a fresh full window.
out = call_tool_sync(STDIO_URL, None, "t", {}, timeout = 0.6, scope = "chat")
assert "timed out" in out
assert time.monotonic() - start < 2.0
def test_close_narrowed_by_headers_spares_other_env(fake_clients):
call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat")
call_tool_sync(STDIO_URL, {"ENV_VAR": "b"}, "t", {}, scope = "chat")
# Two server rows can share a command with different envs; editing one
# must only close its own sessions.
close_stdio_sessions(STDIO_URL, None)
assert fake_clients[0].exited == 1
assert fake_clients[1].exited == 0
assert len(mcp_client._stdio_sessions) == 1
close_stdio_sessions(STDIO_URL) # headers omitted: any env for the command
assert fake_clients[1].exited == 1
assert mcp_client._stdio_sessions == {}
def test_close_stdio_sessions_by_url(fake_clients):
call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat")
call_tool_sync("npx other-server", None, "t", {}, scope = "chat")
key = mcp_client._session_key(STDIO_URL, None, "chat")
close_stdio_sessions(STDIO_URL)
assert fake_clients[0].exited == 1
assert fake_clients[1].exited == 0
assert len(mcp_client._stdio_sessions) == 1
assert key not in mcp_client._stdio_key_locks
def test_execute_tool_mcp_scope_is_per_thread(tmp_path, monkeypatch):
# session_id is the sandbox id and can be shared project-wide; the stdio
# session scope must also carry the per-conversation thread id.
from core.inference import tools as tools_mod
from storage import mcp_servers_db
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(mcp_servers_db, "_schema_ready", False)
monkeypatch.setattr(tools_mod, "stdio_mcp_enabled", lambda: True)
mcp_servers_db.create_server(id = "s1", display_name = "S", url = STDIO_URL, is_enabled = True)
scopes: list = []
def fake_call_tool_sync(**kwargs):
scopes.append(kwargs["scope"])
return "ok"
monkeypatch.setattr(tools_mod, "call_tool_sync", fake_call_tool_sync)
tools_mod.execute_tool("mcp__s1__t", {}, session_id = "project-p1", thread_id = "thread-a")
tools_mod.execute_tool("mcp__s1__t", {}, session_id = "project-p1", thread_id = "thread-b")
tools_mod.execute_tool("mcp__s1__t", {}, session_id = "sess-only")
tools_mod.execute_tool("mcp__s1__t", {}, thread_id = "thread-a")
# Persist only with a thread_id; session_id alone stays one-shot (None) so a
# project-wide id can't leak state across conversations. Fields are tagged.
assert scopes == ["s=project-p1:t=thread-a", "s=project-p1:t=thread-b", None, "s=:t=thread-a"]
# IDs containing ":" must not collapse distinct conversations into one scope,
# and a session-only id must never collide with a thread-only id.
tools_mod.execute_tool("mcp__s1__t", {}, session_id = "a:b", thread_id = "c")
tools_mod.execute_tool("mcp__s1__t", {}, session_id = "a", thread_id = "b:c")
assert scopes[-2] != scopes[-1]
tools_mod.execute_tool("mcp__s1__t", {}, session_id = "same")
tools_mod.execute_tool("mcp__s1__t", {}, thread_id = "same")
assert scopes[-2] != scopes[-1] # session-only "same" != thread-only "same"
def test_execute_tool_config_check_tracks_row(tmp_path, monkeypatch):
from core.inference import tools as tools_mod
from storage import mcp_servers_db
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(mcp_servers_db, "_schema_ready", False)
monkeypatch.setattr(tools_mod, "stdio_mcp_enabled", lambda: True)
mcp_servers_db.create_server(id = "s1", display_name = "S", url = STDIO_URL, is_enabled = True)
captured: dict = {}
monkeypatch.setattr(tools_mod, "call_tool_sync", lambda **kw: captured.update(kw) or "ok")
tools_mod.execute_tool("mcp__s1__t", {})
check = captured["config_check"]
assert check() is True
mcp_servers_db.update_server("s1", {"url": "npx different-server"})
assert check() is False
def test_multi_block_result_flattens_through_session(fake_clients):
async def _rich_call(name, args):
return SimpleNamespace(
content = [
SimpleNamespace(type = "text", text = "### Page"),
SimpleNamespace(type = "text", text = "- Page URL: https://example.com/"),
],
is_error = False,
structured_content = None,
)
call_tool_sync(STDIO_URL, None, "t", {}, scope = "chat")
fake_clients[0].call_tool = _rich_call
out = call_tool_sync(STDIO_URL, None, "browser_snapshot", {}, scope = "chat")
assert out == "### Page\n- Page URL: https://example.com/"
def test_stdio_cache_trims_overshoot_after_burst(fake_clients, monkeypatch):
# A concurrent burst of distinct-scope calls can overshoot the cap while every
# session is busy (insert-time eviction only reclaims idle sessions). Once the
# calls finish, release-time trimming must bring the cache back within cap.
monkeypatch.setattr(mcp_client, "_STDIO_MAX_SESSIONS", 2)
def slow_client(
url,
headers,
use_oauth = False,
):
client = FakeClient(url)
client.call_delay = 0.5 # keep every session in-flight during the burst
return client
monkeypatch.setattr(mcp_client, "_client", slow_client)
errors: list = []
def worker(i: int):
try:
call_tool_sync(STDIO_URL, None, "t", {}, scope = f"chat-{i}")
except Exception as exc: # noqa: BLE001
errors.append(exc)
threads = [threading.Thread(target = worker, args = (i,)) for i in range(5)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(10.0)
assert not errors, errors
assert len(mcp_client._stdio_sessions) <= 2
def test_close_http_server_creates_no_stdio_tombstone(fake_clients):
# HTTP/SSE servers are never cached as stdio sessions, so closing one on
# update/delete must not accrue a close-generation entry (an unbounded leak).
before_cfg = len(mcp_client._stdio_cfg_close_gen)
before_url = len(mcp_client._stdio_url_close_gen)
for i in range(50):
close_stdio_sessions(f"https://mcp-{i}.example/mcp", {"K": str(i)})
close_stdio_sessions(f"https://mcp-{i}.example/mcp")
assert len(mcp_client._stdio_cfg_close_gen) == before_cfg
assert len(mcp_client._stdio_url_close_gen) == before_url
# a real stdio command still registers a generation (the guard is non-stdio only)
close_stdio_sessions(STDIO_URL, {"K": "v"})
assert len(mcp_client._stdio_cfg_close_gen) == before_cfg + 1

View file

@ -4,6 +4,8 @@ import sys
import types
from types import SimpleNamespace
import pytest
class _DummyMetal:
@staticmethod
@ -185,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
@ -208,6 +333,118 @@ def test_mlx_generate_chat_response_accepts_template_kwargs():
), f"{name!r} must default to None so existing callers stay valid"
def test_mlx_vlm_generation_selects_renderer_by_capability(monkeypatch):
from core.inference.mlx_inference import MLXInferenceBackend
calls = {"generic": [], "model": [], "stream": []}
state = {"generic": "serialized", "model": "<image> model-aware"}
prompt_utils = SimpleNamespace(
MODEL_CONFIG = {"deepseek_vl_v2": object()},
apply_chat_template = lambda *_args, **kwargs: (
calls["model"].append(kwargs) or state["model"]
),
)
mlx_vlm = types.ModuleType("mlx_vlm")
mlx_vlm.prompt_utils = prompt_utils
mlx_vlm.stream_generate = lambda *_args, **kwargs: (
calls["stream"].append((_args, kwargs))
or iter([SimpleNamespace(text = "ok", prompt_tokens = 3, generation_tokens = 1)])
)
monkeypatch.setitem(sys.modules, "mlx_vlm", mlx_vlm)
def generic(_target, _messages, **kwargs):
calls["generic"].append(kwargs)
if isinstance(state["generic"], Exception):
raise state["generic"]
if state["generic"] == "serialized":
return f"User: {_messages[0]['content']}"
return state["generic"]
monkeypatch.setattr(
"core.inference.chat_template_helpers.apply_chat_template_for_generation",
generic,
)
backend = MLXInferenceBackend()
backend._model = SimpleNamespace(config = {"model_type": "deepseek_vl_v2"})
backend._processor = SimpleNamespace(tokenizer = SimpleNamespace())
args = ([{"role": "user", "content": [{"type": "image"}]}], object(), 0, 1, 0, 0, 1, 1, None)
tools = [{"function": {"name": "search"}}]
assert list(backend._generate_vlm(*args)) == ["ok"]
assert calls["model"][0]["num_images"] == 1
assert calls["stream"][0][0][2] == "<image> model-aware"
with pytest.raises(RuntimeError, match = "dropping requested tools"):
list(backend._generate_vlm(*args, tools = tools))
with pytest.raises(RuntimeError, match = "dropping requested tools or reasoning"):
list(backend._generate_vlm(*args, enable_thinking = False))
backend._processor = SimpleNamespace(chat_template = "template")
state["generic"] = "<image> healthy generic"
assert list(backend._generate_vlm(*args, tools = tools, enable_thinking = False)) == ["ok"]
assert calls["generic"][-1]["enable_thinking"] is False
assert calls["stream"][-1][0][2] == "<image> healthy generic"
state["generic"] = "generic prompt"
text_messages = [{"role": "user", "content": "hello"}]
assert list(backend._generate_vlm(*((text_messages, None) + args[2:]), tools = tools)) == ["ok"]
assert calls["generic"][-1]["tools"] == tools
assert calls["stream"][-1][0][2] == "generic prompt"
two_images = [{"role": "user", "content": [{"type": "image"}, {"type": "image"}]}]
with pytest.raises(RuntimeError, match = "2 structured image item"):
list(backend._generate_vlm(*((two_images,) + args[1:]), tools = tools))
state["generic"] = "serialized"
tool_history = args[0] + [{"role": "assistant", "tool_calls": [{"id": "call-1"}]}]
with pytest.raises(RuntimeError, match = "tool-call history"):
list(backend._generate_vlm(*((tool_history,) + args[1:]), tools = tools))
state["generic"] = ValueError("generic rendering failed")
state["model"] = f"User: {args[0][0]['content']}"
with pytest.raises(ValueError, match = "generic rendering failed"):
list(backend._generate_vlm(*args))
def test_mlx_vlm_image_injection_reuses_media_aliases(monkeypatch):
from core.inference.mlx_inference import MLXInferenceBackend, _prompt_serializes_vlm_media
media = [{"type": "image"}]
quoted = [{"role": "user", "content": media}, {"role": "user", "content": f"Explain {media}"}]
assert _prompt_serializes_vlm_media(f"<image>\n{media[0]}", quoted[:1])
assert not _prompt_serializes_vlm_media(f"<image>\nExplain {media}", quoted)
assert _prompt_serializes_vlm_media(f"User: {media}\nExplain {media}", quoted)
quoted[1]["content"] = [{"type": "text", "text": f'Explain "this" {media}'}]
assert not _prompt_serializes_vlm_media(f'<image>\nExplain "this" {media}', quoted)
json_media = [{"type": "image_url"}]
json_repr = '{"type": "image_url"}'
assert _prompt_serializes_vlm_media(f"<image>\n{json_repr}", [{"content": json_media}])
assert not _prompt_serializes_vlm_media(
f"<image>\nExplain {json_repr}",
[{"content": json_media}, {"content": f"Explain {json_repr}"}],
)
backend = MLXInferenceBackend()
backend._model = object()
backend._is_vlm = True
captured = []
backend._generate_vlm = lambda messages, *_args, **_kwargs: (
captured.append(messages) or iter(())
)
messages = [{"role": "user", "content": [{"type": "image_url"}]}]
list(backend.generate_chat_response(messages, image = object()))
assert captured[0][0]["content"] == [{"type": "image_url"}]
def test_mlx_vlm_model_config_prefers_config_with_model_type():
from core.inference.mlx_inference import _mlx_vlm_model_config
# config present but missing model_type must fall back to _config
m = SimpleNamespace(config = {}, _config = {"model_type": "deepseek_vl_v2"})
assert _mlx_vlm_model_config(m) == ({"model_type": "deepseek_vl_v2"}, "deepseek_vl_v2")
# an object config whose model_type is None also falls back
m = SimpleNamespace(config = SimpleNamespace(model_type = None), _config = {"model_type": "qwen2_vl"})
assert _mlx_vlm_model_config(m)[1] == "qwen2_vl"
# a config that already carries a model_type is preferred and returned unchanged
assert _mlx_vlm_model_config(SimpleNamespace(config = {"model_type": "gemma3"})) == (
{"model_type": "gemma3"},
"gemma3",
)
def test_mlx_generate_text_forwards_kwargs_into_template_helper(monkeypatch):
"""Mac text path must route through apply_chat_template_for_generation so
reasoning / tool kwargs reach the tokenizer."""

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