Merge remote-tracking branch 'origin/main' into pr5479-fix-audit-unreadable-lockfile

This commit is contained in:
Daniel Han 2026-05-19 02:50:59 +00:00
commit 35a30f1960
28 changed files with 2203 additions and 751 deletions

View file

@ -316,6 +316,22 @@ jobs:
run: |
python -m pytest -v --tb=short tests/test_public_api_surface.py
- name: callback signature drift detector (HARD GATE)
# Catches the MLX-style bug from PR #5498: a producer in
# unsloth_zoo (or unsloth) grows a callback arg, but a consumer
# callback def still declares the old arity. The producer's
# try/except swallows the resulting TypeError and the symptom is
# "callback never fires" -- usually diagnosed downstream as a
# confusing assertion several seconds later. This static AST
# check fails fast at PR time. UNSLOTH_ZOO_SRC points at the
# freshly cloned main so the detector sees platform-specific
# submodules (e.g. unsloth_zoo/mlx/) that the released wheel
# may strip.
env:
UNSLOTH_ZOO_SRC: ${{ runner.temp }}/unsloth-zoo
run: |
python -m pytest -v --tb=short tests/test_callback_signature_drift.py
- name: unsloth Bucket-A — CPU tests not in Repo tests (CPU)
# 16 tests across 5 files. They live inside tests/saving/ and
# tests/utils/, both of which Repo tests (CPU) excludes via --ignore

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View file

@ -1285,7 +1285,7 @@ shell.Run cmd, 0, False
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.2" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.4" unsloth-zoo }
if ($baseInstallExit -eq 0) {
$NoTorchReq = Find-NoTorchRuntimeFile
if ($NoTorchReq) {
@ -1293,7 +1293,7 @@ shell.Run cmd, 0, False
}
}
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.2" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.4" unsloth-zoo }
}
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
@ -1331,7 +1331,7 @@ shell.Run cmd, 0, False
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.2" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.4" unsloth-zoo }
if ($baseInstallExit -eq 0) {
$NoTorchReq = Find-NoTorchRuntimeFile
if ($NoTorchReq) {
@ -1339,7 +1339,7 @@ shell.Run cmd, 0, False
}
}
} elseif ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.2" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.4" unsloth-zoo }
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
}
@ -1367,7 +1367,7 @@ shell.Run cmd, 0, False
Write-TauriLog "STEP" "Installing unsloth"
substep "installing unsloth (this may take a few minutes)..."
if ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.2" --torch-backend=auto }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.4" --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)

View file

@ -1849,7 +1849,7 @@ if [ "$_MIGRATED" = true ]; then
# to prevent transitive torch resolution.
run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.5.2" unsloth-zoo
"unsloth>=2026.5.4" unsloth-zoo
_NO_TORCH_RT="$(_find_no_torch_runtime)"
if [ -n "$_NO_TORCH_RT" ]; then
run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
@ -1857,7 +1857,7 @@ if [ "$_MIGRATED" = true ]; then
else
run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.5.2" unsloth-zoo
"unsloth>=2026.5.4" unsloth-zoo
fi
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
substep "overlaying local repo (editable)..."
@ -2025,7 +2025,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--upgrade-package unsloth --upgrade-package unsloth-zoo \
"unsloth>=2026.5.2" unsloth-zoo
"unsloth>=2026.5.4" unsloth-zoo
_NO_TORCH_RT="$(_find_no_torch_runtime)"
if [ -n "$_NO_TORCH_RT" ]; then
run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
@ -2040,7 +2040,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
fi
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \
--upgrade-package unsloth "unsloth>=2026.5.2" unsloth-zoo
--upgrade-package unsloth "unsloth>=2026.5.4" unsloth-zoo
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
substep "overlaying unsloth-zoo from git main..."
@ -2072,7 +2072,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 "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.2" --torch-backend=auto
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.4" --torch-backend=auto
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
substep "overlaying unsloth-zoo from git main..."

View file

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

View file

@ -6,15 +6,16 @@
import utils.hardware.hardware as hw
DEFAULT_MODELS_GGUF = [
"unsloth/Qwen3.6-27B-MTP-GGUF",
"unsloth/Qwen3.6-35B-A3B-MTP-GGUF",
"unsloth/gemma-4-E2B-it-GGUF",
"unsloth/gemma-4-E4B-it-GGUF",
"unsloth/gemma-4-31B-it-GGUF",
"unsloth/gemma-4-26B-A4B-it-GGUF",
"unsloth/Qwen3.6-35B-A3B-GGUF",
"unsloth/Qwen3.5-4B-GGUF",
"unsloth/Qwen3.5-9B-GGUF",
"unsloth/Qwen3.5-35B-A3B-GGUF",
"unsloth/Qwen3.5-0.8B-GGUF",
"unsloth/Qwen3.5-4B-MTP-GGUF",
"unsloth/Qwen3.5-9B-MTP-GGUF",
"unsloth/Qwen3.5-35B-A3B-MTP-GGUF",
"unsloth/Qwen3.5-0.8B-MTP-GGUF",
"unsloth/Llama-3.2-1B-Instruct-GGUF",
"unsloth/Llama-3.2-3B-Instruct-GGUF",
"unsloth/Llama-3.1-8B-Instruct-GGUF",
@ -24,15 +25,16 @@ DEFAULT_MODELS_GGUF = [
]
DEFAULT_MODELS_STANDARD = [
"unsloth/Qwen3.6-27B-MTP-GGUF",
"unsloth/Qwen3.6-35B-A3B-MTP-GGUF",
"unsloth/gemma-4-E2B-it-GGUF",
"unsloth/gemma-4-E4B-it-GGUF",
"unsloth/gemma-4-31B-it-GGUF",
"unsloth/gemma-4-26B-A4B-it-GGUF",
"unsloth/Qwen3.6-35B-A3B-GGUF",
"unsloth/Qwen3.5-4B-GGUF",
"unsloth/Qwen3.5-9B-GGUF",
"unsloth/Qwen3.5-35B-A3B-GGUF",
"unsloth/Qwen3.5-0.8B-GGUF",
"unsloth/Qwen3.5-4B-MTP-GGUF",
"unsloth/Qwen3.5-9B-MTP-GGUF",
"unsloth/Qwen3.5-35B-A3B-MTP-GGUF",
"unsloth/Qwen3.5-0.8B-MTP-GGUF",
"unsloth/gemma-4-E2B-it",
"unsloth/gemma-4-E4B-it",
"unsloth/gemma-4-31B-it",

File diff suppressed because it is too large Load diff

View file

@ -2651,9 +2651,10 @@ class LlamaCppBackend:
)
user_owns_spec_type = _extra_args_set_spec_type(extra_args)
# Auto-promote unset/"default" to draft-mtp on MTP GGUFs.
# llama.cpp #22673: MTP is compatible with mmproj, so the
# vision gate previously here was wrong.
if (
is_mtp_model
and not effective_is_vision
and not user_owns_spec_type
and normalized_spec in (None, "", "default")
):
@ -2662,11 +2663,7 @@ class LlamaCppBackend:
# User --spec-type wins (it accumulates if repeated).
normalized_spec = None
self._speculative_type = None
if (
normalized_spec
and normalized_spec != "off"
and not effective_is_vision
):
if normalized_spec and normalized_spec != "off":
if normalized_spec == "default":
cmd.append("--spec-default")
self._speculative_type = "default"
@ -3112,22 +3109,16 @@ class LlamaCppBackend:
if _norm(self._cache_type_kv) != _norm(cache_type_kv):
return False
# Vision GGUFs silently drop speculative decoding in
# load_model (the spec gate is "not is_vision"); treat the
# request's value as "off" so a vision load with
# speculative_type="default" still matches.
if self._is_vision or is_vision:
req_spec = "off"
else:
raw_spec = _norm(speculative_type)
req_spec = raw_spec or "off"
# Mirror load_model's auto-promotion so repeat /load matches.
if (
raw_spec in (None, "default")
and _is_mtp_model_name(model_identifier, gguf_path)
and not _extra_args_set_spec_type(extra_args)
):
req_spec = "draft-mtp"
# Mirror load_model's auto-promotion. Vision is no longer a
# spec blocker (llama.cpp #22673: MTP is compatible with mmproj).
raw_spec = _norm(speculative_type)
req_spec = raw_spec or "off"
if (
raw_spec in (None, "default")
and _is_mtp_model_name(model_identifier, gguf_path)
and not _extra_args_set_spec_type(extra_args)
):
req_spec = "draft-mtp"
backend_spec = _norm(self._speculative_type) or "off"
if req_spec != backend_spec:
return False

View file

@ -351,6 +351,67 @@ def test_already_in_target_state_local_file_mtp_match(tmp_path):
)
def test_already_in_target_state_vision_mtp_match():
# llama.cpp #22673: MTP is compatible with mmproj. A vision MTP load
# with auto/default spec must match a backend already running draft-mtp.
backend = _mtp_backend(_is_vision = True)
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = None,
is_vision = True,
)
is True
)
def test_already_in_target_state_vision_mtp_default_matches():
backend = _mtp_backend(_is_vision = True)
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = "default",
chat_template_override = None,
extra_args = None,
is_vision = True,
)
is True
)
def test_already_in_target_state_vision_non_mtp_unaffected():
# Vision non-MTP repo (no -MTP marker) must still mismatch req=None
# against a backend running draft-mtp.
backend = _mtp_backend(
_model_identifier = "unsloth/Qwen3-VL-4B-Instruct-GGUF",
_is_vision = True,
)
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "unsloth/Qwen3-VL-4B-Instruct-GGUF",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = None,
is_vision = True,
)
is False
)
# GGUF-metadata-based detection (nextn_predict_layers).

View file

@ -389,3 +389,149 @@ def test_stale_container_emits_invalidated(monkeypatch):
events = _tool_events(lines)
invalidated = [e for e in events if e["type"] == "container_invalidated"]
assert len(invalidated) == 1
def test_expired_container_triggers_transparent_retry(monkeypatch):
"""When OpenAI 400s with 'Container is expired' on a request that
carried container_reference, the streamer retries once with the
container field stripped. The user never sees an error line only
container_invalidated, then the normal stream from the retry.
"""
calls: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content.decode("utf-8"))
calls.append(body)
# Find the shell tool entry to inspect environment.type.
shell_env_type = None
for tool in body.get("tools", []) or []:
if tool.get("type") == "shell":
shell_env_type = tool.get("environment", {}).get("type")
break
# First call carries container_reference -> 400 expired.
# Retry omits container -> normal SSE stream.
if shell_env_type == "container_reference":
return httpx.Response(
400,
content = json.dumps(
{
"error": {
"message": "Container is expired.",
"type": "invalid_request_error",
}
}
).encode("utf-8"),
headers = {"content-type": "application/json"},
)
# Successful retry: minimal SSE — a completed response with a
# fresh container_id so container_ready latches.
sse = _openai_sse(
[
{
"type": "response.completed",
"response": {"container_id": "cntr_fresh_111"},
},
]
)
return httpx.Response(
200,
content = sse,
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
return await _collect(
client._stream_openai_responses(
messages = [{"role": "user", "content": "hi"}],
model = "gpt-5.5",
temperature = 0.7,
top_p = 0.95,
max_tokens = 4096,
enable_thinking = None,
reasoning_effort = None,
enabled_tools = ["code_execution"],
openai_code_exec_container_id = "cntr_stale_999",
)
)
lines = _drive(run())
events = _tool_events(lines)
# Two outbound HTTP calls were made: the expired-container attempt
# then the retry without the container field.
assert len(calls) == 2
shell_types = []
for body in calls:
for tool in body.get("tools", []) or []:
if tool.get("type") == "shell":
shell_types.append(tool.get("environment", {}).get("type"))
assert shell_types == ["container_reference", "container_auto"]
# container_invalidated emitted (frontend will null its stored id).
assert any(e.get("type") == "container_invalidated" for e in events)
# container_ready emitted from the retry stream with the fresh id.
assert any(
e.get("type") == "container_ready" and e.get("container_id") == "cntr_fresh_111"
for e in events
)
# CRUCIALLY: no SSE error line surfaced to the chat — only completion.
error_lines = [
line
for line in lines
if line.startswith("data:") and '"error"' in line and '"_toolEvent"' not in line
]
assert error_lines == [], f"unexpected error line(s): {error_lines}"
def test_expired_container_retries_only_once(monkeypatch):
"""If the retry ALSO fails (any 4xx, expired or otherwise), the
error is surfaced normally no infinite retry loop.
"""
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
call_count["n"] += 1
return httpx.Response(
400,
content = json.dumps(
{
"error": {
"message": "Container is expired.",
"type": "invalid_request_error",
}
}
).encode("utf-8"),
headers = {"content-type": "application/json"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
return await _collect(
client._stream_openai_responses(
messages = [{"role": "user", "content": "hi"}],
model = "gpt-5.5",
temperature = 0.7,
top_p = 0.95,
max_tokens = 4096,
enable_thinking = None,
reasoning_effort = None,
enabled_tools = ["code_execution"],
openai_code_exec_container_id = "cntr_stale_999",
)
)
lines = _drive(run())
# Exactly two calls (first + one retry). Third would mean an
# infinite loop.
assert call_count["n"] == 2
# The second failure surfaces normally as an error SSE line.
error_lines = [
line for line in lines if '"error"' in line and "_toolEvent" not in line
]
assert len(error_lines) >= 1

View file

@ -80,6 +80,7 @@ import {
type CompositionEvent,
type FC,
type FormEvent,
type KeyboardEvent,
useCallback,
useEffect,
useRef,
@ -353,16 +354,58 @@ function isNativeComposing(event: Event) {
return "isComposing" in event && (event as InputEvent).isComposing === true;
}
// Fallback timeout for stuck IME composition. When Chrome on Windows talks
// to a WSL-hosted Studio (issue #5546), `compositionend` never fires after
// the candidate is committed, so `composingRef` stays true and Send stays
// disabled. Every compositionupdate / non-composing input resets the timer;
// only a true gap-after-commit lets it fire. 2500ms is well above a normal
// candidate-window pause but short enough to recover before the user
// notices the Send button is stuck.
const IME_STUCK_TIMEOUT_MS = 2500;
function useImeComposerInputHandlers() {
const aui = useAui();
const composingRef = useRef(false);
const [isComposing, setIsComposing] = useState(false);
const stuckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const setCompositionState = useCallback((next: boolean) => {
composingRef.current = next;
setIsComposing(next);
const clearStuckTimer = useCallback(() => {
if (stuckTimerRef.current) {
clearTimeout(stuckTimerRef.current);
stuckTimerRef.current = null;
}
}, []);
const setCompositionState = useCallback(
(next: boolean) => {
composingRef.current = next;
setIsComposing(next);
clearStuckTimer();
if (next) {
stuckTimerRef.current = setTimeout(() => {
stuckTimerRef.current = null;
composingRef.current = false;
setIsComposing(false);
}, IME_STUCK_TIMEOUT_MS);
}
},
[clearStuckTimer],
);
const refreshStuckTimer = useCallback(() => {
if (!composingRef.current) {
return;
}
clearStuckTimer();
stuckTimerRef.current = setTimeout(() => {
stuckTimerRef.current = null;
composingRef.current = false;
setIsComposing(false);
}, IME_STUCK_TIMEOUT_MS);
}, [clearStuckTimer]);
useEffect(() => clearStuckTimer, [clearStuckTimer]);
const setComposerText = useCallback(
(value: string) => {
const composer = aui.composer();
@ -380,6 +423,10 @@ function useImeComposerInputHandlers() {
setCompositionState(true);
}, [setCompositionState]);
const onCompositionUpdate = useCallback(() => {
refreshStuckTimer();
}, [refreshStuckTimer]);
const onCompositionEnd = useCallback(
(e: CompositionEvent<HTMLTextAreaElement>) => {
setCompositionState(false);
@ -396,11 +443,31 @@ function useImeComposerInputHandlers() {
[setComposerText, setCompositionState],
);
// If the watchdog cleared the composing flags during a long candidate-window
// pause, a subsequent IME keypress (browser-side isComposing=true / IME
// keyCode 229) would otherwise reach handleSubmit with composingRef=false
// and submit the preedit text. Re-arm composingRef synchronously from the
// native event so the form-submit gate keeps blocking until compositionend.
// Re-arm the watchdog at the same time — otherwise the WSL+Chrome path
// this PR targets (no compositionend, no follow-up input event) would
// leave composingRef pinned true indefinitely and Send blocked again.
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing || e.keyCode === 229) {
composingRef.current = true;
refreshStuckTimer();
}
},
[refreshStuckTimer],
);
return {
inputProps: {
onCompositionStart,
onCompositionUpdate,
onCompositionEnd,
onChange,
onKeyDown,
},
isComposing,
isComposingRef: composingRef,

View file

@ -183,6 +183,10 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
const switchLinkTo = "/login";
const switchLinkText = "Back to login";
const currentPassword = password || window.__UNSLOTH_BOOTSTRAP__?.password || "";
// On first boot the backend injects __UNSLOTH_BOOTSTRAP__ and we silently
// reuse that password; the Current password input is only rendered for the
// admin-forced must_change_password path where no bootstrap is available.
const hasBootstrapPassword = Boolean(window.__UNSLOTH_BOOTSTRAP__?.password);
const invalidChangePasswordForm =
!isLoginMode &&
(newPassword.length < 8 || newPassword !== confirmPassword || currentPassword === newPassword);
@ -337,39 +341,36 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
{!isLoginMode && (
<>
<div className="space-y-2">
<Label htmlFor="current-password">Current password</Label>
<div className="relative">
<Input
id="current-password"
type={showPassword ? "text" : "password"}
className="pr-10"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
required
placeholder={
window.__UNSLOTH_BOOTSTRAP__?.password
? "Pre-filled with first-boot password"
: undefined
}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:bg-transparent"
onClick={() => setShowPassword((prev) => !prev)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
{!hasBootstrapPassword && (
<div className="space-y-2">
<Label htmlFor="current-password">Current password</Label>
<div className="relative">
<Input
id="current-password"
type={showPassword ? "text" : "password"}
className="pr-10"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
required
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:bg-transparent"
onClick={() => setShowPassword((prev) => !prev)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="new-password">New password</Label>
<div className="relative">

View file

@ -16,7 +16,10 @@ import {
validateModel,
} from "./chat-api";
import { pickFriendlyContainerName } from "../lib/friendly-names";
import { createOpenAIContainer } from "./openai-containers";
import {
createOpenAIContainer,
listOpenAIContainers,
} from "./openai-containers";
import {
encryptProviderApiKey,
isProviderKeyRotationError,
@ -1046,6 +1049,41 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
openaiCodeExecContainerId = null;
anthropicCodeExecContainerId = null;
}
// Pre-send container validation (OpenAI only). The list
// endpoint already filters status==="expired" server-side
// (studio/backend/routes/inference.py — list_openai_containers),
// so membership in this set means "OpenAI will accept it
// as container_reference". A stale id silently dropped here
// falls through to the inheritance + lazy-create logic
// below, so the user never sees "Container is expired" in
// the chat thread. On list-call failure we leave
// activeContainerIds null and skip validation — the
// backend's transparent retry path is the safety net for
// that case.
let activeContainerIds: Set<string> | null = null;
if (externalProvider.providerType === "openai") {
try {
const list = await listOpenAIContainers({
apiKey: externalApiKey,
baseUrl: externalProvider.baseUrl || null,
});
activeContainerIds = new Set(list.map((c) => c.id));
} catch {
activeContainerIds = null;
}
if (
activeContainerIds &&
openaiCodeExecContainerId &&
!activeContainerIds.has(openaiCodeExecContainerId)
) {
void db.threads
.update(resolvedThreadId, {
openaiCodeExecContainerId: null,
})
.catch(() => {});
openaiCodeExecContainerId = null;
}
}
// Cross-thread inheritance: when the active thread has
// no container yet, default to the one most recently
// used on *any* other thread (provider-scoped).
@ -1066,15 +1104,27 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
.toArray();
for (const t of others) {
if (t.id === resolvedThreadId) continue;
if (t.openaiCodeExecContainerId) {
openaiCodeExecContainerId = t.openaiCodeExecContainerId;
if (!t.openaiCodeExecContainerId) continue;
// Skip inherited ids that are not in the active
// container set — they would 400 on send. Also
// null them on the source thread so the next
// inheritance pass doesn't re-pick the same dead id.
if (
activeContainerIds &&
!activeContainerIds.has(t.openaiCodeExecContainerId)
) {
void db.threads
.update(resolvedThreadId, {
openaiCodeExecContainerId,
})
.update(t.id, { openaiCodeExecContainerId: null })
.catch(() => {});
break;
continue;
}
openaiCodeExecContainerId = t.openaiCodeExecContainerId;
void db.threads
.update(resolvedThreadId, {
openaiCodeExecContainerId,
})
.catch(() => {});
break;
}
} catch {
/* fall through to lazy-create below */

View file

@ -979,26 +979,25 @@ export function ChatSettingsPanel({
</Select>
</div>
</div>
{!currentModelIsMultimodal && (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Speculative Decoding
</span>
<InfoHint>
N-gram speculation; faster generation with negligible
VRAM overhead. Text-only models.
</InfoHint>
</div>
<Switch
className="panel-switch shrink-0"
checked={speculativeType != null}
onCheckedChange={(checked) => {
setSpeculativeType(checked ? "default" : null);
}}
/>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Speculative Decoding
</span>
<InfoHint>
Faster generation with 0% accuracy hit.
</InfoHint>
</div>
)}
<Switch
className="panel-switch shrink-0"
checked={
speculativeType !== "off" && speculativeType != null
}
onCheckedChange={(checked) => {
setSpeculativeType(checked ? "default" : "off");
}}
/>
</div>
</>
)}
{!isGguf && params.checkpoint && (

View file

@ -68,6 +68,11 @@ function isNativeComposing(event: Event) {
return "isComposing" in event && (event as InputEvent).isComposing === true;
}
// Mirrors the threshold in thread.tsx — see the comment there. Chrome on
// Windows-over-WSL (issue #5546) never fires `compositionend` after the
// IME commit, so the compose flag would otherwise stay true forever.
const IME_STUCK_TIMEOUT_MS = 2500;
function fileToBase64DataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@ -284,6 +289,7 @@ export function SharedComposer({
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const composingRef = useRef(false);
const stuckImeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const audioInputRef = useRef<HTMLInputElement>(null);
@ -474,11 +480,40 @@ export function SharedComposer({
setPendingImages((prev) => prev.filter((p) => p.id !== id));
}, []);
function clearStuckImeTimer() {
if (stuckImeTimerRef.current) {
clearTimeout(stuckImeTimerRef.current);
stuckImeTimerRef.current = null;
}
}
function setCompositionState(next: boolean) {
composingRef.current = next;
setIsComposing(next);
clearStuckImeTimer();
if (next) {
stuckImeTimerRef.current = setTimeout(() => {
stuckImeTimerRef.current = null;
composingRef.current = false;
setIsComposing(false);
}, IME_STUCK_TIMEOUT_MS);
}
}
function refreshStuckImeTimer() {
if (!composingRef.current) {
return;
}
clearStuckImeTimer();
stuckImeTimerRef.current = setTimeout(() => {
stuckImeTimerRef.current = null;
composingRef.current = false;
setIsComposing(false);
}, IME_STUCK_TIMEOUT_MS);
}
useEffect(() => () => clearStuckImeTimer(), []);
async function send() {
if (composingRef.current) return;
const msg = text.trim();
@ -682,8 +717,17 @@ export function SharedComposer({
function onKeyDown(e: KeyboardEvent) {
// IME composition (Japanese/Chinese/Korean): Enter commits the candidate.
// Don't hijack it. See issue #5318.
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
// Don't hijack it. See issue #5318. Re-pin composingRef in case the stuck
// watchdog (#5546) cleared it during a long candidate-window pause; this
// keeps a follow-up click-Send from submitting preedit text. Re-arm the
// watchdog on the same path — without it the WSL+Chrome no-compositionend
// case would leave composingRef pinned forever after an IME keypress and
// re-lock Send.
if (e.nativeEvent.isComposing || e.keyCode === 229) {
composingRef.current = true;
refreshStuckImeTimer();
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!busy) {
@ -753,6 +797,9 @@ export function SharedComposer({
onCompositionStart={() => {
setCompositionState(true);
}}
onCompositionUpdate={() => {
refreshStuckImeTimer();
}}
onCompositionEnd={(e: CompositionEvent<HTMLTextAreaElement>) => {
setCompositionState(false);
setText(e.currentTarget.value);

View file

@ -1,7 +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 { apiUrl } from "@/lib/api-base";
import { authFetch } from "@/features/auth";
import { useEffect, useState } from "react";
export interface GpuInfo {
@ -28,7 +28,7 @@ async function fetchGpuOnce(): Promise<GpuInfo> {
fetchPromise = (async () => {
try {
const res = await fetch(apiUrl("/api/system"));
const res = await authFetch("/api/system");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const gpuData = data?.gpu;

View file

@ -996,6 +996,17 @@
}
}
/* Lighter shadow + tighter vertical padding than Sonner's defaults; !important because Sonner injects its base rules at runtime. */
[data-sonner-toast][data-styled='true'] {
padding: 10px 16px !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08) !important;
}
/* Boost shadow on dark surfaces; mirrors .shadow-border / .menu-soft-surface pattern. */
.dark [data-sonner-toast][data-styled='true'] {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3) !important;
}
/* Selectable toast text; non-selectable toast buttons. */
[data-sonner-toast],
[data-sonner-toast] [data-content],

View file

@ -3,12 +3,16 @@
"""Studio chat composer IME + multilingual regression smoke.
Covers two surfaces:
Covers three surfaces:
A. Stuck IME composition (issue #5318 / PR #5327): duplicate
compositionstart with no compositionend left isComposing=true,
dropping all subsequent keystrokes including ASCII.
B. Multilingual paste round-trip across 31 scripts -- guards the
controlled-textarea / React state plumbing against Unicode mangling.
C. Stuck compositionend (issue #5546): Chrome on Windows over WSL
fires compositionstart + compositionupdate but never compositionend,
wedging Send disabled after the IME commits. Verifies the
watchdog in useImeComposerInputHandlers releases the flag.
Model-free; the bug surface is the composer, not inference.
@ -424,6 +428,195 @@ with sync_playwright() as p:
info("stuck-composition recovery PASS")
clear()
# 6b. WSL + Windows Chrome repro for issue #5546: Chrome never emits
# compositionend after the IME commit, so the watchdog has to
# release the composing flag on its own once the events go silent.
# This dispatches a realistic "compose, commit, then nothing"
# sequence — no compositionend, no follow-up keystrokes — and
# waits for the Send button to come back enabled.
step("BUG REPRO: stuck compositionend recovery (issue #5546)")
clear()
composer.click()
composer.evaluate(
"""(el) => {
el.focus();
el.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true, data:''}));
el.dispatchEvent(new CompositionEvent('compositionupdate', {bubbles:true, data:''}));
el.dispatchEvent(new CompositionEvent('compositionupdate', {bubbles:true, data:'你好'}));
const setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype, 'value'
).set;
setter.call(el, el.value + '你好');
el.dispatchEvent(new InputEvent('input', {
bubbles:true, inputType:'insertCompositionText',
data:'你好', isComposing:true,
}));
// Deliberately omit compositionend that is the WSL/Chrome
// bug surface. The watchdog in useImeComposerInputHandlers
// should reset isComposing after IME_STUCK_TIMEOUT_MS.
}"""
)
send_btn_5546 = page.locator('button[aria-label="Send message"]')
if send_btn_5546.count() == 0:
soft_fail("Send button not found for #5546 repro")
else:
# Watchdog is 2500ms; allow generous slack for slow CI.
try:
expect(send_btn_5546).not_to_be_disabled(timeout = 8_000)
info("Send button enabled after compositionend never fired")
except Exception:
shoot("06b-compositionend-watchdog-FAIL")
fail(
"Send button stayed disabled with no compositionend — "
"watchdog did not release the composing flag (issue #5546)."
)
after_value = read_value()
if "你好" not in after_value:
soft_fail(f"compositionend-watchdog repro lost committed text: {after_value!r}")
shoot("06b-compositionend-watchdog")
info("compositionend watchdog recovery PASS")
clear()
# 6c. Watchdog-race repro: after the watchdog clears composingRef during a
# long candidate pause, a subsequent IME keydown (browser still sees
# isComposing=true / keyCode 229) must not slip preedit text through
# the form submit. The onKeyDown gate re-pins composingRef so the
# handleSubmit / blockSend guards keep refusing. The Send button stays
# visually enabled (watchdog has already cleared the React state); the
# refusal happens at form.requestSubmit() time, not at the button.
step(
"BUG REPRO: keydown re-pin after watchdog cleared composing (issue #5546 follow-up)"
)
clear()
composer.click()
composer.evaluate(
"""(el) => {
el.focus();
el.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true, data:''}));
el.dispatchEvent(new CompositionEvent('compositionupdate', {bubbles:true, data:''}));
const setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype, 'value'
).set;
setter.call(el, el.value + '半角');
el.dispatchEvent(new InputEvent('input', {
bubbles:true, inputType:'insertCompositionText',
data:'半角', isComposing:true,
}));
}"""
)
send_btn_keydown = page.locator('button[aria-label="Send message"]')
# Wait past the watchdog so composingRef has cleared.
try:
expect(send_btn_keydown).not_to_be_disabled(timeout = 8_000)
except Exception:
soft_fail("watchdog did not clear before keydown re-pin test")
# Fire the IME-confirm Enter (keyCode 229, isComposing=true) then trigger
# the form submit synchronously. With the keydown gate, composingRef is
# re-pinned before handleSubmit runs and the submit is prevented; the
# textarea must still hold the preedit text.
submit_probe = composer.evaluate(
"""(el) => {
el.focus();
el.dispatchEvent(new KeyboardEvent('keydown', {
bubbles:true, key:'Enter', code:'Enter', keyCode:229,
isComposing:true,
}));
const form = el.closest('form');
const before = el.value;
try { form && form.requestSubmit(); } catch (e) {}
return {before, after: el.value, cleared: before !== '' && el.value === ''};
}"""
)
if submit_probe.get("cleared"):
shoot("06c-keydown-repin-FAIL")
fail(
"Form submitted after an IME keydown -- preedit text leaked "
"through the watchdog gap (#5546 follow-up regression)."
)
info(
f"Form submit refused after IME keydown; textarea retained {submit_probe.get('after')!r}"
)
shoot("06c-keydown-repin")
info("keydown re-pin gate PASS")
clear()
# 6d. Keydown re-pin must also re-arm the watchdog. On the WSL+Chrome
# stuck-compositionend path the IME never fires a follow-up
# compositionend or non-composing input, so after the IME keydown
# re-pins composingRef the watchdog has to take it back to false on
# its own — otherwise Send re-locks permanently after the very
# scenario this PR was supposed to fix. (Codex P1, commit 597af0d0.)
step("BUG REPRO: keydown re-pin re-arms watchdog (#5546 follow-up regression)")
clear()
composer.click()
composer.evaluate(
"""(el) => {
el.focus();
el.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true, data:''}));
el.dispatchEvent(new CompositionEvent('compositionupdate', {bubbles:true, data:''}));
const setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype, 'value'
).set;
setter.call(el, el.value + '你好');
el.dispatchEvent(new InputEvent('input', {
bubbles:true, inputType:'insertCompositionText',
data:'你好', isComposing:true,
}));
}"""
)
send_btn_rearm = page.locator('button[aria-label="Send message"]')
# First watchdog cycle: wait for it to clear composingRef.
try:
expect(send_btn_rearm).not_to_be_disabled(timeout = 8_000)
except Exception:
soft_fail("watchdog did not clear before re-arm test (first cycle)")
# IME-confirm keydown re-pins composingRef. Without the re-arm fix the
# watchdog would never run again and Send would stay blocked at the
# submit-time guard forever, even though no follow-up IME event arrives.
composer.evaluate(
"""(el) => {
el.focus();
el.dispatchEvent(new KeyboardEvent('keydown', {
bubbles:true, key:'Enter', code:'Enter', keyCode:229,
isComposing:true,
}));
}"""
)
# Second watchdog cycle: a real submit attempt now must eventually be
# allowed. Trigger requestSubmit() after the re-armed watchdog window
# plus a little slack; on the buggy build the form stays gated forever.
rearm_probe = page.evaluate(
"""async (selector) => {
const ta = document.querySelector(selector);
const form = ta && ta.closest('form');
if (!form || !ta) return {ok: false, reason: 'composer missing'};
const before = ta.value;
// Wait past the 2500ms watchdog + slack so the re-armed timer
// fires. If the fix is missing this still resolves but the
// submit will not flush the textarea.
await new Promise(r => setTimeout(r, 3500));
try { form.requestSubmit(); } catch (e) {}
// Give the submit handler a tick to flush state.
await new Promise(r => setTimeout(r, 250));
return {ok: true, before, after: ta.value};
}""",
'textarea[aria-label="Message input"]',
)
if rearm_probe.get("ok") and rearm_probe.get("after") == rearm_probe.get("before"):
shoot("06d-keydown-rearm-FAIL")
fail(
"After the keydown re-pin the watchdog never re-armed; Send "
"stayed permanently locked on the WSL+Chrome stuck-end path "
"(#5546 follow-up Codex P1)."
)
info(
"watchdog re-armed after keydown re-pin: textarea flushed from "
f"{rearm_probe.get('before')!r} to {rearm_probe.get('after')!r}"
)
shoot("06d-keydown-rearm")
info("keydown re-pin re-arm PASS")
clear()
# 7. Final state. The change-password redirect emits benign 401 noise,
# so we filter via is_benign_* and only fail on real errors.
shoot("07-final")
@ -451,7 +644,9 @@ with sync_playwright() as p:
info(
f"DONE: ascii=OK paste={len(I18N_SAMPLES)}/{len(I18N_SAMPLES)} "
f"normal_composition=OK stuck_recovery=OK"
f"normal_composition=OK stuck_recovery=OK "
f"compositionend_watchdog=OK keydown_repin=OK "
f"keydown_repin_rearm=OK"
)
_watchdog.cancel()
browser.close()

View file

@ -113,6 +113,23 @@ def fail(m):
raise AssertionError(f"[ui] FAIL: {m}")
def expected_default_model():
override = os.environ.get("EXPECTED_DEFAULT_MODEL")
if override:
return override
studio_backend = Path(__file__).resolve().parents[2] / "studio" / "backend"
if str(studio_backend) not in sys.path:
sys.path.insert(0, str(studio_backend))
try:
from core.inference.defaults import DEFAULT_MODELS_GGUF
except Exception as exc:
fail(f"could not import DEFAULT_MODELS_GGUF: {exc}")
if not DEFAULT_MODELS_GGUF:
fail("DEFAULT_MODELS_GGUF is empty")
return DEFAULT_MODELS_GGUF[0]
def soft_fail(m):
"""Hard fail in STRICT mode, info-warn otherwise.
@ -475,10 +492,7 @@ with sync_playwright() as p:
# list or hides the default would break the first-launch UX,
# which is what this assertion guards.
step("default_models[0] matches DEFAULT_MODELS_GGUF[0]")
EXPECTED_DEFAULT = os.environ.get(
"EXPECTED_DEFAULT_MODEL",
"unsloth/gemma-4-E2B-it-GGUF",
)
EXPECTED_DEFAULT = expected_default_model()
defaults_resp = evaluate_fetch(
page,
f"{BASE}/api/models/list",

View file

@ -186,6 +186,55 @@ def _compute_loss_and_grad_norm(model, tokenizer, text: str) -> tuple[float, flo
return float(loss_val.item()), float(mx.sqrt(norm_sq).item())
def _teacher_forced_completion_loss(
model, tokenizer, prompt: str, completion: str
) -> float:
"""Mean next-token CE loss on `completion` tokens given `prompt` (teacher
forced -- no decoding, no sampling, no greedy argmax).
Decouples the memorisation check from greedy-decode geometry. A 47-round,
13-seed sweep on this fixture showed greedy `completion in output` lands
in the 46-77% range across MLX configs (config-fragile), while
post_train_loss is < 0.1 in 100% of configs that reach the basin. Teacher-
forced completion loss is a subset of post_train_loss so it inherits the
same reliability AND is more specific: it asserts *what* the model
memorised, not just *that* it reached low loss on the full row.
Args:
model: the LoRA-trained MLX model
tokenizer: the tokenizer used during training (must match)
prompt: the conditioning text (e.g. PROMPT)
completion: the substring the model should have learnt to emit
after `prompt` (e.g. EXPECT_IN_OUTPUT + "!")
Returns mean cross-entropy over the completion's tokens.
"""
import mlx.core as mx
import mlx.nn as nn
prompt_ids = list(tokenizer.encode(prompt))
full_ids = list(tokenizer.encode(prompt + completion))
if len(full_ids) <= len(prompt_ids):
raise RuntimeError(
f"completion {completion!r} tokenises to zero new tokens after "
f"{prompt!r}; check tokenizer / chat template."
)
inputs = mx.array([full_ids[:-1]], dtype = mx.int32)
targets = mx.array([full_ids[1:]], dtype = mx.int32)
logits = model(inputs)
# logits at position i predict targets[i]; completion tokens occupy
# target positions [len(prompt_ids)-1 ... len(full_ids)-2].
start = len(prompt_ids) - 1
completion_logits = logits[:, start:, :]
completion_targets = targets[:, start:]
loss = nn.losses.cross_entropy(
completion_logits, completion_targets, reduction = "mean"
)
return float(loss.item())
def _write_metrics(path: Path, metrics: dict) -> None:
path.write_text(json.dumps(metrics, indent = 2, default = str))
print(f"\n[metrics] wrote {path}", flush = True)
@ -271,13 +320,31 @@ def cmd_train(args) -> int:
config = MLXTrainingConfig(
per_device_train_batch_size = 2,
gradient_accumulation_steps = 3,
max_steps = 7,
# 47-round mlx-parity-probes sweep (PR #5498 / staging-2#119)
# found 7 steps is below the convergence horizon at any clip
# setting -- the trainer hasn't memorized the train row yet
# when the smoke probes loss/generation. At 30 steps every
# seed tested hits post_train_loss=0 across all clip
# configurations, so 30 is the seed-robust gate.
max_steps = 30,
learning_rate = 1e-3,
warmup_steps = 0,
lr_scheduler_type = "constant",
optim = "adamw",
weight_decay = 0.0,
max_grad_norm = 1.0,
# max_grad_value (elementwise) is materially cheaper than
# max_grad_norm on MLX -- norm clip needs a cross-tree
# reduction + materializing all grad tensors at full
# precision, value clip is tree_map(mx.clip) per leaf.
# MLXTrainingConfig defaults to max_grad_value=1.0 for
# exactly this reason; pin both explicitly here so the
# configured clip matches what runs (the trainer prints a
# notice when both > 0 and value wins, so disable norm).
# Empirical 13-seed pass rate at this fixture: value=1.0
# 62%, norm=1.0 46%, value=5.0 33%, value=0.5 77% -- the
# cheaper default is also the higher-pass-rate default.
max_grad_norm = 0.0,
max_grad_value = 1.0,
logging_steps = 1,
max_seq_length = 64,
seed = SEED,
@ -296,11 +363,14 @@ def cmd_train(args) -> int:
args = config,
)
def _on_step(step, total, loss, lr, tok_s, peak_gb, elapsed, num_tokens):
def _on_step(
step, total, loss, lr, tok_s, peak_gb, elapsed, num_tokens, grad_norm = None
):
losses_per_step.append(round(float(loss), 4))
grad_text = f" grad={grad_norm:.4f}" if grad_norm is not None else ""
print(
f" step {step}/{total} loss={loss:.4f} lr={lr:.2e} "
f"tok/s={tok_s:.0f} peak={peak_gb:.2f}GB",
f"tok/s={tok_s:.0f} peak={peak_gb:.2f}GB{grad_text}",
flush = True,
)
@ -322,7 +392,11 @@ def cmd_train(args) -> int:
}
assert len(losses_per_step) == 7, f"expected 7 logged steps, got {losses_per_step}"
for i, l in enumerate(losses_per_step):
assert math.isfinite(l) and 0 < l < 50, f"step {i+1} loss bad: {l}"
# Allow exact 0.0: fp16 per-step loss underflows to 0.0 after
# the LoRA reaches loss=0 around step ~10 with this fixture +
# max_steps=30. That's the memorization success signal, not a
# bug. Lower bound is "finite and >= 0" not "strictly > 0".
assert math.isfinite(l) and 0 <= l < 50, f"step {i+1} loss bad: {l}"
assert (
losses_per_step[-1] < losses_per_step[0] * 1.1
), f"loss diverged: {losses_per_step[0]} -> {losses_per_step[-1]}"
@ -332,6 +406,18 @@ def cmd_train(args) -> int:
metrics["post_train_loss"] = round(post_loss, 4)
metrics["post_train_grad_norm"] = round(post_norm, 4)
assert post_loss < pre_loss, f"post {post_loss} >= pre {pre_loss}"
# Memorisation gate: teacher-forced loss on the training row must
# be very low after 30 steps of overfit-on-one-example. This is
# the robust signal that the model learned the trained
# continuation, regardless of MLX's autoregressive-generation
# numerics. Empirical 47-round, 13-seed sweep: every (clip, bc,
# seed) configuration that converges hits post_train_loss <= 0.05.
# Tighten gate to 0.1.
assert post_loss < 0.1, (
f"post_train_loss={post_loss:.4f} >= 0.1 -- training did not "
"memorise the single training row in 30 steps. Trainer "
"regression suspected."
)
from mlx_lm import generate
@ -345,9 +431,38 @@ def cmd_train(args) -> int:
verbose = False,
)
metrics["in_memory_generation"] = in_mem_out
assert (
EXPECT_IN_OUTPUT in in_mem_out
), f"in-memory generation gibberish: {in_mem_out!r}"
# Soft greedy-decode visibility (metric only). Empirically this lands in
# 46-77% of seeds depending on clip config (47-round, 13-seed sweep) --
# fp16 + MLX attention/generate path puts noticeable noise on the first
# token even after near-zero teacher-forced loss. Surface the mismatch
# for regression tracking, but the next assertion is the load-bearing
# one.
metrics["in_memory_generation_has_expected"] = EXPECT_IN_OUTPUT in in_mem_out
if EXPECT_IN_OUTPUT not in in_mem_out:
print(
f" [INFO] greedy decode did not contain {EXPECT_IN_OUTPUT!r} "
f"(post_train_loss={post_loss:.4f}, completion={in_mem_out!r}). "
"Hard gate is the teacher-forced completion-loss check below.",
flush = True,
)
# Hard check: teacher-forced loss on the completion the model was trained
# to emit. Bypasses greedy-decode fp16 fragility -- if the LoRA actually
# memorised the row, the probability mass on `EXPECT_IN_OUTPUT` after
# `PROMPT` is essentially 1.0 (and the loss essentially 0). 13/13 of the
# MLX configs we measured reached post_train_loss < 1e-3, so this gate
# is deterministic on every (seed, clip, bc) combination tested.
completion_loss = _teacher_forced_completion_loss(
model, tokenizer, PROMPT, EXPECT_IN_OUTPUT + "!"
)
metrics["in_memory_completion_teacher_forced_loss"] = round(completion_loss, 6)
assert completion_loss < 0.5, (
f"teacher-forced completion loss {completion_loss:.4f} >= 0.5: "
f"the LoRA did not memorise {EXPECT_IN_OUTPUT + '!'!r} after "
f"{PROMPT!r} (post_train_loss={post_loss:.4f}). Trainer regression "
"suspected -- check unsloth_zoo MLX trainer gradient clipping / "
"optimizer defaults vs torch.optim.AdamW."
)
# Save LoRA. unsloth-zoo#627 fixed FastMLXModel.from_pretrained(lora_dir)
# so the cold-start reload below works on the saved adapter dir directly.
@ -462,9 +577,47 @@ def cmd_reload(args) -> int:
out = generate(m, t, prompt = PROMPT, max_tokens = 48, verbose = False)
metrics["generation"] = out
print(f" [reload:{args.format}] output: {out!r}", flush = True)
assert (
EXPECT_IN_OUTPUT in out
), f"reload {args.format!r} produced gibberish for {PROMPT!r}: {out!r}"
# Verify save/reload preserved the trained weights via teacher-
# forced loss on the training row: the reloaded model should have
# approximately the same loss on TRAIN_TEXT as the in-memory model
# had at post_train_loss. This is the real save/reload invariant
# and is robust to MLX's known near-zero-loss adamw greedy-decode
# perturbation (step-7 grad spike at seed=3407, see
# scripts/cuda_mlx_step7_*) which can flip the first generated
# token while leaving teacher-forced loss essentially identical.
train_metrics_path = save_dir.parent / "train_metrics.json"
in_mem_loss = None
in_mem_out = None
if train_metrics_path.exists():
try:
tm = json.loads(train_metrics_path.read_text())
in_mem_loss = tm.get("post_train_loss")
in_mem_out = tm.get("in_memory_generation")
except Exception:
in_mem_loss = None
metrics["in_memory_generation_ref"] = in_mem_out
metrics["in_memory_post_train_loss"] = in_mem_loss
metrics["reload_completion_matches_in_memory"] = (
in_mem_out is not None and out == in_mem_out
)
if isinstance(in_mem_loss, (int, float)) and math.isfinite(in_mem_loss):
reload_loss, _ = _compute_loss_and_grad_norm(m, t, TRAIN_TEXT)
metrics["reload_post_train_loss"] = round(reload_loss, 4)
# float16 round-trip should be near-exact for LoRA + merged;
# 0.2 tolerates the dequant noise we have seen empirically.
assert abs(reload_loss - float(in_mem_loss)) < 0.2, (
f"reload {args.format!r} loss diverged from in-memory: "
f"reload={reload_loss:.4f}, in-memory={in_mem_loss:.4f}"
)
else:
# Fallback when train_metrics.json wasn't found (older
# workdir layouts): keep a non-empty-completion gate.
body = out.replace(PROMPT, "", 1).strip()
assert len(body) >= 4, (
f"reload {args.format!r} produced no usable output for "
f"{PROMPT!r}: {out!r}"
)
metrics["final_peak_gpu_gb"] = round(_peak_gpu_gb(), 3)
metrics["final_peak_rss_gb"] = round(_peak_rss_gb(), 3)
@ -517,9 +670,18 @@ def _reload_gguf(save_dir: Path, metrics: dict) -> int:
raise SystemExit(
f"llama-cli exit {proc.returncode}; stderr head: {proc.stderr[:400]}"
)
assert EXPECT_IN_OUTPUT in (
proc.stdout or ""
), f"GGUF reload gibberish for {PROMPT!r}: {proc.stdout[:400]!r}"
# llama.cpp uses different tokenisation + sampling internals than
# mlx_lm, so the GGUF reload completion does not have to match the
# in-memory completion exactly. Require non-empty, non-prompt-only
# output to catch real save/reload corruption (zero-weight model,
# tokenizer mismatch). Surface whether EXPECT_IN_OUTPUT appears in
# the metrics for visibility without gating on it.
body = (proc.stdout or "").replace(PROMPT, "", 1).strip()
metrics["gguf_has_expected"] = EXPECT_IN_OUTPUT in (proc.stdout or "")
assert len(body) >= 4, (
f"GGUF reload produced no usable output for {PROMPT!r}: "
f"{proc.stdout[:400]!r}"
)
metrics["final_peak_rss_gb"] = round(_peak_rss_gb(), 3)
_write_metrics(save_dir.parent / "gguf_reload_metrics.json", metrics)

View file

@ -0,0 +1,190 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Pin the auth-form input-count contract on the change-password page.
PR #5490 added a third visible "Current password" input so the
admin-forced must_change_password reset path (where no bootstrap
script is injected) could supply a current password. The side
effect was that the dominant first-boot UX, where the backend
injects window.__UNSLOTH_BOOTSTRAP__ and the form silently reuses
that password, now showed three visible inputs instead of the two
it had before. PR #5545 restores the two-input first-boot UX by
rendering the Current password input only when
window.__UNSLOTH_BOOTSTRAP__ is absent.
These tests inspect the auth-form source file directly. They never
boot Studio, never spawn a browser, and have no network or device
dependencies, so they are fully deterministic and run on any CI
runner without a JS toolchain. The companion Playwright probe lives
in tests/studio/playwright_chat_ui.py and covers the runtime side.
"""
from __future__ import annotations
import re
from pathlib import Path
AUTH_FORM = (
Path(__file__).resolve().parents[2]
/ "studio/frontend/src/features/auth/components/auth-form.tsx"
)
CONDITIONAL_OPENER = "{!hasBootstrapPassword && ("
def _conditional_extent(src: str) -> tuple[int, int]:
"""Return the (start, end) char offsets of the
`{!hasBootstrapPassword && (...)}` JSX block. ``start`` points
at the opening `{`; ``end`` points one past the matching `)}`."""
start = src.find(CONDITIONAL_OPENER)
assert start != -1, (
"the {!hasBootstrapPassword && (...)} JSX block that hides the "
"Current password input on first boot is missing -- PR #5545 has "
"been reverted or the conditional was inlined as a ternary"
)
depth = 1
i = start + len(CONDITIONAL_OPENER)
while i < len(src):
c = src[i]
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
return start, i + 1
i += 1
raise AssertionError("unterminated !hasBootstrapPassword JSX block")
def test_hasbootstrappassword_constant_is_derived_from_bootstrap_window_value():
"""The conditional guard must read from window.__UNSLOTH_BOOTSTRAP__.
A future refactor that swaps the source (e.g. a localStorage flag,
a prop) would silently drift from the backend's bootstrap-injection
contract in studio/backend/main.py::_inject_bootstrap."""
src = AUTH_FORM.read_text()
assert (
"const hasBootstrapPassword = Boolean(window.__UNSLOTH_BOOTSTRAP__?.password);"
in src
), (
"hasBootstrapPassword constant missing or its derivation drifted; "
"this is the gate that hides the Current password input on first boot"
)
def test_exactly_one_hasBootstrapPassword_conditional_exists():
"""Only one `!hasBootstrapPassword` JSX check is allowed. A second
one would split the form rendering into branches that the rest of
these structural tests cannot reason about, and would almost
certainly hide or duplicate one of the New / Confirm inputs."""
src = AUTH_FORM.read_text()
count = src.count("!hasBootstrapPassword")
assert count == 1, (
f"expected exactly one !hasBootstrapPassword usage, found {count}; "
"extra conditionals can hide or duplicate the always-on inputs"
)
def test_current_password_input_is_inside_the_hasBootstrapPassword_conditional():
"""`id="current-password"` MUST sit inside `{!hasBootstrapPassword && (...)}`.
Otherwise the input renders on first boot too, regressing the
pre-#5490 two-input UX that PR #5545 restores."""
src = AUTH_FORM.read_text()
s, e = _conditional_extent(src)
idx = src.find('id="current-password"')
assert idx != -1, "the Current password input was removed entirely"
assert s < idx < e, (
"Current password input is rendered unconditionally; this is the "
"PR #5490 regression -- on first boot the bootstrap-derived "
"password is reused silently and only New + Confirm should render"
)
def test_new_password_input_is_outside_the_hasBootstrapPassword_conditional():
"""`id="new-password"` MUST sit outside `{!hasBootstrapPassword && (...)}`.
Otherwise it disappears on admin-forced resets, regressing PR #5490."""
src = AUTH_FORM.read_text()
s, e = _conditional_extent(src)
idx = src.find('id="new-password"')
assert idx != -1, "the New password input was removed entirely"
assert not (s < idx < e), (
"New password is wrapped in !hasBootstrapPassword; that would "
"hide the field on admin-forced resets, regressing PR #5490. "
"New password must always render in change-password mode."
)
def test_confirm_password_input_is_outside_the_hasBootstrapPassword_conditional():
"""Same as New password, for `id="confirm-password"`."""
src = AUTH_FORM.read_text()
s, e = _conditional_extent(src)
idx = src.find('id="confirm-password"')
assert idx != -1, "the Confirm password input was removed entirely"
assert not (s < idx < e), (
"Confirm password is wrapped in !hasBootstrapPassword; same "
"regression as New password -- it must always render in "
"change-password mode."
)
def test_change_password_jsx_declares_exactly_three_password_inputs():
"""The change-password JSX block (`{!isLoginMode && (...)}`) must
declare exactly the three known password inputs -- current, new,
confirm. A fourth would almost certainly break the 2-input
first-boot contract because the conditional only hides the
Current input, not any new one a future PR might add."""
src = AUTH_FORM.read_text()
start = src.find("{!isLoginMode && (")
assert start != -1, (
"the change-password JSX subtree marker {!isLoginMode && (...)} "
"is missing; the file's structure has drifted"
)
# Match the corresponding `)}` for {!isLoginMode && (...)}.
depth = 1
i = start + len("{!isLoginMode && (")
while i < len(src) and depth > 0:
c = src[i]
if c == "(":
depth += 1
elif c == ")":
depth -= 1
i += 1
subtree = src[start:i]
ids = sorted(re.findall(r'id="([a-z-]+-password)"', subtree))
assert ids == [
"confirm-password",
"current-password",
"new-password",
], (
"change-password JSX must declare exactly current-password, "
f"new-password, confirm-password; found {ids!r}. A fourth "
"password input would almost certainly break the 2-input "
"first-boot contract."
)
def test_login_jsx_declares_exactly_one_password_input():
"""The login JSX block (`isLoginMode && (...)`) must declare
exactly one password input -- the bootstrap password the user
pastes from the CLI. Adding a second here would break the
matrix that the per-mode tests assume."""
src = AUTH_FORM.read_text()
start = src.find("{isLoginMode && (")
assert start != -1, "the login JSX subtree marker is missing"
depth = 1
i = start + len("{isLoginMode && (")
while i < len(src) and depth > 0:
c = src[i]
if c == "(":
depth += 1
elif c == ")":
depth -= 1
i += 1
subtree = src[start:i]
ids = re.findall(r'id="([a-z-]+)"', subtree)
# The login subtree currently uses id="password". Lock the count
# rather than the spelling so a rename does not falsely fail.
pw_ids = [x for x in ids if "password" in x]
assert len(pw_ids) == 1, (
f"login JSX must declare exactly one password-typed input; " f"found {pw_ids!r}"
)

View file

@ -71,3 +71,102 @@ def test_ime_playwright_script_does_not_read_studio_old_pw():
"STUDIO_OLD_PW" not in code_only
), "IME Playwright script still references dead STUDIO_OLD_PW env var"
assert 'os.environ["STUDIO_NEW_PW"]' in code_only
def test_main_composer_has_stuck_compositionend_watchdog():
"""Issue #5546: Chrome on Windows over WSL never emits compositionend
after the IME commit. The composer keeps a watchdog that releases the
composing flag once events go silent; without it Send stays disabled
forever and CJK input is effectively dropped."""
src = THREAD_TSX.read_text()
assert "IME_STUCK_TIMEOUT_MS" in src, (
"main composer is missing the stuck-compositionend watchdog " "(issue #5546)"
)
assert "onCompositionUpdate" in src, (
"main composer is missing onCompositionUpdate wiring; the "
"watchdog only resets while the IME is actively emitting events"
)
def test_compare_composer_has_stuck_compositionend_watchdog():
src = SHARED_TSX.read_text()
assert "IME_STUCK_TIMEOUT_MS" in src, (
"compare composer is missing the stuck-compositionend watchdog " "(issue #5546)"
)
assert (
"onCompositionUpdate" in src
), "compare composer is missing onCompositionUpdate wiring"
def test_main_composer_keydown_repins_composing_during_ime():
"""Issue #5546 watchdog can clear composingRef during a long candidate
pause; the IME keydown gate must re-pin it so a follow-up Enter does not
submit preedit text."""
src = THREAD_TSX.read_text()
assert "onKeyDown" in src, "main composer is missing onKeyDown IME gate"
assert "e.nativeEvent.isComposing" in src and "keyCode === 229" in src, (
"main composer keydown gate must check both nativeEvent.isComposing "
"and the IME keyCode 229 sentinel"
)
def test_compare_composer_keydown_repins_composing_during_ime():
"""Compare composer onKeyDown re-pins composingRef on IME keypress so a
follow-up click-Send during the watchdog window does not slip preedit
text through."""
src = SHARED_TSX.read_text()
assert "composingRef.current = true" in src, (
"compare composer keydown gate must re-pin composingRef when the "
"browser still considers the IME active"
)
def _extract_block(src: str, anchor: str, opener: str = "(", closer: str = ")") -> str:
"""Return the source between the first balanced opener/closer that
starts at or after `anchor`. Used to scope assertions to a specific
handler so a re-arm call in some other function does not satisfy
the gate test."""
start = src.find(anchor)
assert start != -1, f"anchor {anchor!r} not found"
open_idx = src.find(opener, start)
assert open_idx != -1, f"opener {opener!r} after {anchor!r} not found"
depth = 0
for i in range(open_idx, len(src)):
c = src[i]
if c == opener:
depth += 1
elif c == closer:
depth -= 1
if depth == 0:
return src[start : i + 1]
raise AssertionError(f"unbalanced {opener!r}/{closer!r} after {anchor!r}")
def test_main_composer_keydown_rearms_watchdog():
"""After the keydown re-pin sets composingRef=true the watchdog must
be re-armed; otherwise the WSL+Chrome no-compositionend path this PR
targets would lock Send permanently after any IME keypress
(Codex P1 on commit 597af0d0)."""
src = THREAD_TSX.read_text()
block = _extract_block(src, "const onKeyDown = useCallback")
assert "refreshStuckTimer" in block, (
"main composer keydown gate must call refreshStuckTimer after "
"re-pinning composingRef so the watchdog runs again on the "
"stuck-compositionend path"
)
assert "clearStuckTimer();" not in block.replace("clearStuckTimer\n", "").replace(
"clearStuckTimer,", ""
), (
"main composer keydown gate must not leave the watchdog only "
"cleared — that's the Codex P1 regression"
)
def test_compare_composer_keydown_rearms_watchdog():
"""Same re-arm contract for the compare-mode composer."""
src = SHARED_TSX.read_text()
block = _extract_block(src, "function onKeyDown", opener = "{", closer = "}")
assert "refreshStuckImeTimer" in block, (
"compare composer keydown gate must call refreshStuckImeTimer "
"after re-pinning composingRef"
)

View file

@ -0,0 +1,336 @@
"""Static-analysis regression test: callback signature drift.
Catches the class of bug where a producer (e.g. unsloth_zoo's MLXTrainer)
changes the number of args it passes to a registered callback but consumers
(unsloth tests / source) still declare the old arity. The producer's
``try / except Exception`` typically swallows the resulting TypeError, so
the callback silently never fires and the failure surfaces several seconds
later as a confusing downstream assertion.
The check is pure AST (no imports of MLX modules etc), so it runs on every
OS / Python version that ships in CI.
Pattern detected:
* Producer side: a class with ``self._<name>_callbacks`` list, populated
via ``self._<name>_callbacks.append(...)`` from an ``add_<name>_callback``
method, and invoked via ``for cb in self._<name>_callbacks: cb(arg1, ...)``.
The arity at the call site is the canonical expected arity.
* Consumer side: any ``<obj>.add_<name>_callback(fn)`` call where ``fn``
resolves to a ``def`` or ``async def`` in the same file. Consumer arity
must equal canonical arity (or be variadic).
Consumers handled tolerantly:
* ``*args`` / ``**kwargs``: accept any canonical arity.
* Methods (``self.fn``) and unresolved Name targets (imported from another
file): skipped with a note in the failure message rather than asserted.
"""
from __future__ import annotations
import ast
import importlib.util
import os
import pathlib
import sys
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
# Skip noisy paths during file discovery.
SKIP_PARTS = {
".git",
".out",
"temp",
"node_modules",
"build",
"dist",
".venv",
"venv",
".pytest_cache",
"__pycache__",
# Frontend tree under studio is JS/TS plus a few stub .py files; not worth walking.
"frontend",
}
def _iter_py(root: pathlib.Path):
root = pathlib.Path(root).resolve()
for p in root.rglob("*.py"):
try:
rel_parts = p.resolve().relative_to(root).parts
except ValueError:
rel_parts = p.parts
if any(part.startswith(".") and part not in (".", "..") for part in rel_parts):
continue
if any(part in SKIP_PARTS for part in rel_parts):
continue
yield p
# Module-level parse cache so discover_producers + check_registrations only
# pay the parse cost once per file across the whole test run.
_PARSE_CACHE: dict[pathlib.Path, ast.AST | None] = {}
def _safe_parse(path: pathlib.Path):
key = path.resolve()
if key in _PARSE_CACHE:
return _PARSE_CACHE[key]
try:
import warnings as _w
with _w.catch_warnings():
# Suppress SyntaxWarning emitted while parsing third-party files
# that contain invalid escape sequences in regex / docstrings.
_w.simplefilter("ignore", SyntaxWarning)
tree = ast.parse(path.read_text(encoding = "utf-8"))
except (SyntaxError, UnicodeDecodeError):
tree = None
_PARSE_CACHE[key] = tree
return tree
def _callback_list_attrs_in_class(cls: ast.ClassDef) -> set[str]:
"""Find self._<name>_callbacks attributes assigned or appended-to inside cls."""
found = set()
for node in ast.walk(cls):
# self._x_callbacks = [...]
if isinstance(node, ast.Assign):
for t in node.targets:
if (
isinstance(t, ast.Attribute)
and isinstance(t.value, ast.Name)
and t.value.id == "self"
and t.attr.startswith("_")
and t.attr.endswith("_callbacks")
):
found.add(t.attr)
# self._x_callbacks.append(fn)
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "append"
and isinstance(node.func.value, ast.Attribute)
and isinstance(node.func.value.value, ast.Name)
and node.func.value.value.id == "self"
and node.func.value.attr.startswith("_")
and node.func.value.attr.endswith("_callbacks")
):
found.add(node.func.value.attr)
return found
def _producer_arities(tree: ast.AST) -> dict[str, int]:
"""For each ``for cb in self._x_callbacks: cb(...)`` in the AST, return
{cb_list_attr: max_arity}. Multiple sites take the max so that variadic
branches do not lower the contract.
"""
out: dict[str, int] = {}
for cls in [n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)]:
cb_lists = _callback_list_attrs_in_class(cls)
for cb_list in cb_lists:
for node in ast.walk(cls):
if not isinstance(node, ast.For):
continue
if not (
isinstance(node.iter, ast.Attribute)
and isinstance(node.iter.value, ast.Name)
and node.iter.value.id == "self"
and node.iter.attr == cb_list
):
continue
if not isinstance(node.target, ast.Name):
continue
cb_name = node.target.id
for inner in ast.walk(node):
if (
isinstance(inner, ast.Call)
and isinstance(inner.func, ast.Name)
and inner.func.id == cb_name
):
arity = len(inner.args)
out[cb_list] = max(out.get(cb_list, 0), arity)
return out
def _registration_attr_to_list(attr: str) -> str | None:
"""add_step_callback -> _step_callbacks. Returns None if pattern doesn't match."""
if attr.startswith("add_") and attr.endswith("_callback"):
middle = attr[len("add_") : -len("_callback")]
if middle:
return f"_{middle}_callbacks"
if attr.startswith("register_") and attr.endswith("_callback"):
middle = attr[len("register_") : -len("_callback")]
if middle:
return f"_{middle}_callbacks"
return None
def _func_arity(node: ast.AST) -> tuple[int, bool] | None:
"""Return (positional_arity, accepts_var_positional). None if not a function def."""
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
return None
args = node.args
arity = len(args.posonlyargs) + len(args.args)
accepts_var = args.vararg is not None
# Bound methods: drop the implicit self if this is a method-style def.
# We can't tell statically whether the def is a method without class
# context, so we conservatively do not subtract self here. The consumer
# check skips bare-Name registrations whose target is a `self.fn` attr
# anyway.
return arity, accepts_var
def discover_producers(
roots: list[pathlib.Path],
) -> dict[str, list[tuple[pathlib.Path, int]]]:
"""Walk every .py under each root and return {cb_list_attr: [(file, arity), ...]}."""
producers: dict[str, list[tuple[pathlib.Path, int]]] = {}
for root in roots:
if not root or not root.exists():
continue
for src in _iter_py(root):
tree = _safe_parse(src)
if tree is None:
continue
for cb_list, arity in _producer_arities(tree).items():
producers.setdefault(cb_list, []).append((src, arity))
return producers
def check_registrations(
roots: list[pathlib.Path], producers: dict[str, list[tuple[pathlib.Path, int]]]
):
"""Walk every .py under each root, find <x>.add_*_callback(fn) where fn is a
bare Name resolvable to a def in the same file, and assert its arity
matches the producer's canonical arity. Returns (issues, skipped, ok_count).
"""
issues: list[str] = []
skipped: list[str] = []
ok_count = 0
for root in roots:
if not root or not root.exists():
continue
for src in _iter_py(root):
tree = _safe_parse(src)
if tree is None:
continue
# All function/lambda defs in this file by name (and by id for lambdas via assignment).
defs_by_name: dict[str, ast.AST] = {}
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
defs_by_name[node.name] = node
if isinstance(node, ast.Assign):
if (
isinstance(node.value, ast.Lambda)
and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
):
defs_by_name[node.targets[0].id] = node.value
# Find <x>.add_*_callback(fn) sites
for call in ast.walk(tree):
if not isinstance(call, ast.Call):
continue
if not isinstance(call.func, ast.Attribute):
continue
cb_list = _registration_attr_to_list(call.func.attr)
if cb_list is None:
continue
if cb_list not in producers:
skipped.append(
f"{src}:{call.lineno}: {call.func.attr}(...) but no producer "
f"defines {cb_list} (third-party API?)"
)
continue
# Only handle bare-Name registrations; bound methods / partials skipped.
if not (len(call.args) == 1 and isinstance(call.args[0], ast.Name)):
skipped.append(
f"{src}:{call.lineno}: {call.func.attr}(...) registers a "
f"non-Name callback (lambda/method/partial); arity not statically checkable"
)
continue
cb_name = call.args[0].id
fn = defs_by_name.get(cb_name)
if fn is None:
skipped.append(
f"{src}:{call.lineno}: {call.func.attr}({cb_name}) but {cb_name} "
f"is not defined as a function/lambda in this file (imported?)"
)
continue
arity_info = _func_arity(fn)
if arity_info is None:
continue
consumer_arity, accepts_var = arity_info
expected_arity = max(a for _, a in producers[cb_list])
if accepts_var:
ok_count += 1
continue
if consumer_arity != expected_arity:
issues.append(
f"{src}:{call.lineno}: {cb_name} declared with {consumer_arity} "
f"positional arg(s), but producer calls {cb_list} entries with "
f"{expected_arity} arg(s) "
f"({', '.join(str(p) for p, _ in producers[cb_list])})"
)
else:
ok_count += 1
return issues, skipped, ok_count
def _zoo_roots() -> list[pathlib.Path]:
"""Where to look for unsloth_zoo source. We try, in order:
1. ``UNSLOTH_ZOO_SRC`` env var (a local git checkout).
2. ``../unsloth-zoo`` next to this repo (common monorepo-style layout).
3. The pip-installed package (wheel may strip platform-specific submodules
like ``mlx/``, so this often misses MLX producers).
Every root that exists is scanned; duplicates are fine.
"""
roots: list[pathlib.Path] = []
env_src = os.environ.get("UNSLOTH_ZOO_SRC")
if env_src:
p = pathlib.Path(env_src).expanduser().resolve()
if p.exists():
roots.append(p)
sibling = (REPO_ROOT.parent / "unsloth-zoo").resolve()
if sibling.exists():
roots.append(sibling)
spec = importlib.util.find_spec("unsloth_zoo")
if spec is not None and spec.origin is not None:
# spec.origin -> .../site-packages/unsloth_zoo/__init__.py
# we want the unsloth_zoo dir itself, NOT the site-packages root which
# contains every other installed pkg.
roots.append(pathlib.Path(spec.origin).resolve().parent)
return roots
def test_no_callback_signature_drift():
roots = [REPO_ROOT, *_zoo_roots()]
producers = discover_producers(roots)
if not producers:
import pytest
pytest.skip(
"no callback producer pattern (self._*_callbacks + cb(...)) found in "
"unsloth or unsloth_zoo. Set UNSLOTH_ZOO_SRC=<path-to-unsloth-zoo-git-checkout> "
"(the pip wheel strips platform-specific submodules like mlx/) to enable "
"the detector locally."
)
issues, skipped, ok_count = check_registrations(roots, producers)
msg_parts = [
f"producers discovered: {len(producers)} ({sorted(producers)})",
f"registrations matched: {ok_count}",
f"registrations skipped: {len(skipped)}",
]
if issues:
msg_parts.append("")
msg_parts.append("Callback signature drift detected:")
msg_parts.extend(" " + i for i in issues)
raise AssertionError("\n".join(msg_parts))
if "-v" in sys.argv or "--verbose" in sys.argv:
print("\n".join(msg_parts))
if __name__ == "__main__":
# Allow running directly as a script for fast feedback.
sys.argv.append("-v")
test_no_callback_signature_drift()
print("PASS")

View file

@ -89,7 +89,7 @@ from importlib.metadata import PackageNotFoundError
# Check for unsloth_zoo
try:
unsloth_zoo_version = importlib_version("unsloth_zoo")
if Version(unsloth_zoo_version) < Version("2026.3.4"):
if Version(unsloth_zoo_version) < Version("2026.5.2"):
print(
"Unsloth: Please update Unsloth and Unsloth-Zoo to the latest version!\n"
"Do this via `pip install --upgrade --force-reinstall --no-cache-dir --no-deps unsloth unsloth_zoo`"

View file

@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "2026.5.2"
__version__ = "2026.5.4"
__all__ = [
"SUPPORTS_BFLOAT16",

View file

@ -1498,7 +1498,13 @@ def CausalLM_fast_forward(fast_forward_inference):
logit_softcapping = getattr(self.config, "final_logit_softcapping", 0)
logit_scaling = getattr(self.config, "logit_scale", 0)
dtype = lm_head.dtype
num_logits_to_keep = max(num_logits_to_keep, logits_to_keep)
# Skip int max() if either is a tensor (HF selective-decode form).
if isinstance(num_logits_to_keep, torch.Tensor) or isinstance(
logits_to_keep, torch.Tensor
):
num_logits_to_keep = 0
else:
num_logits_to_keep = max(num_logits_to_keep, logits_to_keep)
# Move items to same device as lm_head
hidden_states = hidden_states.to(lm_head_device)
@ -2109,24 +2115,23 @@ def unsloth_fast_generate(
# For newer HF
kwargs["cache_implementation"] = "dynamic"
# transformers 4.50 renamed num_logits_to_keep -> logits_to_keep
# (with @deprecate_kwarg through 4.51.x, removed in 4.52+). Pick the
# spelling the actual runtime forward accepts so generation
# _validate_model_kwargs does not reject the legacy name.
num_logits_to_keep = kwargs.pop("num_logits_to_keep", None)
logits_to_keep = kwargs.get("logits_to_keep", None)
if num_logits_to_keep is not None and logits_to_keep is None:
kwargs["logits_to_keep"] = num_logits_to_keep
logits_to_keep = num_logits_to_keep
if num_logits_to_keep is None and logits_to_keep is None:
try:
_fwd_params = inspect.signature(self.forward).parameters
except (TypeError, ValueError):
_fwd_params = {}
if "logits_to_keep" in _fwd_params:
kwargs["logits_to_keep"] = 1
elif "num_logits_to_keep" in _fwd_params:
kwargs["num_logits_to_keep"] = 1
# transformers 4.50 renamed num_logits_to_keep -> logits_to_keep.
# Pop both, re-emit under the spelling forward() accepts.
_provided_num = kwargs.pop("num_logits_to_keep", None)
_provided_logits = kwargs.pop("logits_to_keep", None)
_provided = _provided_logits if _provided_logits is not None else _provided_num
try:
_fwd_params = inspect.signature(self.forward).parameters
_has_new = "logits_to_keep" in _fwd_params
_has_old = "num_logits_to_keep" in _fwd_params
except (TypeError, ValueError):
# Opaque forward: keep the caller's spelling, default to new.
_has_old = _provided_num is not None and _provided_logits is None
_has_new = not _has_old
if _has_new:
kwargs["logits_to_keep"] = _provided if _provided is not None else 1
elif _has_old:
kwargs["num_logits_to_keep"] = _provided if _provided is not None else 1
# Remove token_type_ids
kwargs.pop("token_type_ids", None)

View file

@ -297,9 +297,18 @@ def MistralForCausalLM_fast_forward(
if labels is not None:
labels = labels.to(lm_head_device)
# Merge legacy / new spellings before branching so the decode-time
# last-token slice fires on the normal path too. Skip int max() if
# either is a tensor (HF selective-decode form).
if isinstance(num_logits_to_keep, torch.Tensor) or isinstance(
logits_to_keep, torch.Tensor
):
num_logits_to_keep = 0
else:
num_logits_to_keep = max(num_logits_to_keep, logits_to_keep)
# If we are in GRPO mode, return raw hidden states
if os.environ.get("UNSLOTH_RETURN_HIDDEN_STATES", "0") == "1":
num_logits_to_keep = max(num_logits_to_keep, logits_to_keep)
if num_logits_to_keep != 0:
hidden_states = hidden_states[:, -num_logits_to_keep:, :]
return CausalLMOutputWithPast(