Merge remote-tracking branch 'origin/main' into pr5479-fix-audit-unreadable-lockfile
This commit is contained in:
commit
35a30f1960
28 changed files with 2203 additions and 751 deletions
16
.github/workflows/consolidated-tests-ci.yml
vendored
16
.github/workflows/consolidated-tests-ci.yml
vendored
|
|
@ -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 |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
10
install.ps1
10
install.ps1
|
|
@ -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)
|
||||
|
|
|
|||
10
install.sh
10
install.sh
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
190
tests/studio/test_auth_form_input_count.py
Normal file
190
tests/studio/test_auth_form_input_count.py
Normal 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}"
|
||||
)
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
336
tests/test_callback_signature_drift.py
Normal file
336
tests/test_callback_signature_drift.py
Normal 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")
|
||||
|
|
@ -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`"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue