test: combine PR 5434 + PR 5517 test suites (manual merge)

Both PRs added disjoint test functions in the same region of
test_training_worker_flash_attn.py. Concatenate the two test bodies;
no semantic overlap.
This commit is contained in:
danielhanchen 2026-05-18 04:45:07 +00:00
commit 00858b1178
16 changed files with 2571 additions and 597 deletions

View file

@ -229,12 +229,55 @@ jobs:
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
sleep 2
# IME + multilingual paste regression (issue #5318 / PR #5327).
# Third Studio on its own port so a hang here cannot poison the
# earlier UI tests. No GGUF -- the bug surface is the composer.
- name: Reset auth + boot Studio for IME / i18n tests (port 18896)
run: |
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18896 \
> logs/studio_ime.log 2>&1 &
echo "STUDIO_IME_PID=$!" >> "$GITHUB_ENV"
- name: Wait for /api/health on 18896
run: |
for i in $(seq 1 180); do
if curl -fs "http://127.0.0.1:18896/api/health" > /tmp/health3.json; then
jq -e '.status == "healthy"' /tmp/health3.json && break
fi
sleep 1
done
jq -e '.status == "healthy"' /tmp/health3.json
- name: Pass bootstrap pw for IME / i18n test
# IME smoke does the change-password against the bootstrap that
# Studio's frontend injects into the page, so it only needs the
# NEW password.
run: |
NEW="CIIme-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
echo "::add-mask::$NEW"
echo "STUDIO_IME_NEW_PW=$NEW" >> "$GITHUB_ENV"
- name: Drive IME + multilingual paste regression with Playwright
env:
BASE_URL: http://127.0.0.1:18896
STUDIO_NEW_PW: ${{ env.STUDIO_IME_NEW_PW }}
PW_ART_DIR: logs/playwright_ime
STUDIO_UI_STRICT: '1'
run: |
mkdir -p logs/playwright_ime
python tests/studio/playwright_chat_ime_i18n.py
- name: Stop third Studio
if: always()
run: |
kill "${STUDIO_IME_PID}" 2>/dev/null || true
sleep 2
- name: Upload Playwright artifacts
# Always upload (not just failure) so a green run's screenshots
# are reviewable in the Actions UI -- catches "passed but the
# UI is silently broken" regressions that would be invisible
# otherwise. Both Studio's logs (chat + extra) and BOTH
# Playwright artifact dirs are bundled.
# Always upload so a green run's screenshots stay reviewable --
# catches "passed but the UI is silently broken" regressions.
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@ -242,7 +285,9 @@ jobs:
path: |
logs/studio.log
logs/studio_extra.log
logs/studio_ime.log
logs/install.log
logs/playwright
logs/playwright_extra
logs/playwright_ime
retention-days: 7

View file

@ -127,6 +127,7 @@ jobs:
run: |
PYTHONPATH=. python -m pytest \
tests/version_compat/test_peft_pinned_symbols.py \
tests/version_compat/test_unsloth_zoo_save_merged_pinned_symbols.py \
-v --tb=short
st-pinned-symbols:

View file

@ -69,6 +69,7 @@ triton = [
]
huggingfacenotorch = [
"unsloth_zoo>=2026.5.2",
"wheel>=0.42.0",
"packaging",
"numpy",

File diff suppressed because it is too large Load diff

View file

@ -69,7 +69,15 @@ _DENYLIST_GROUPS: tuple[frozenset[str], ...] = (
# Single-model server -- Studio runs one model per llama-server
# process and serves its own UI. Enabling multi-model loading or
# llama-server's built-in web UI changes the surface clients see.
# ``--webui``/``--no-webui`` are the legacy spelling; current
# upstream uses ``--ui``/``--no-ui`` + ``--ui-*`` companions.
# Keep both so the denylist matches old and new llama-server
# binaries (Studio's prebuilt vs system-llama.cpp).
frozenset({"--webui", "--no-webui"}),
frozenset({"--ui", "--no-ui"}),
frozenset({"--ui-config"}),
frozenset({"--ui-config-file"}),
frozenset({"--ui-mcp-proxy", "--no-ui-mcp-proxy"}),
frozenset({"--models-dir"}),
frozenset({"--models-preset"}),
frozenset({"--models-max"}),
@ -118,3 +126,95 @@ def validate_extra_args(args: Optional[Iterable[str]]) -> list[str]:
def is_managed_flag(flag: str) -> bool:
"""True if ``flag`` is a Studio-managed llama-server flag."""
return flag in _DENYLIST
# Pass-through flags that shadow first-class ``LoadRequest`` fields
# (max_seq_length, cache_type_kv, speculative_type,
# chat_template_override). Stripped from inherited extras so they
# can't last-wins-override an Apply that re-sets the same first-class
# field.
_CONTEXT_FLAGS: frozenset[str] = frozenset({"-c", "--ctx-size"})
_CACHE_FLAGS: frozenset[str] = frozenset(
{"-ctk", "--cache-type-k", "-ctv", "--cache-type-v"}
)
_SPEC_FLAGS: frozenset[str] = frozenset(
{
"--spec-default",
"--spec-type",
"--spec-ngram-size-n",
"--spec-ngram-size",
"--draft-min",
"--draft-max",
}
)
_TEMPLATE_FLAGS: frozenset[str] = frozenset(
{
"--chat-template",
"--chat-template-file",
"--chat-template-kwargs",
"--jinja",
"--no-jinja",
}
)
_SHADOWING_FLAGS: frozenset[str] = (
_CONTEXT_FLAGS | _CACHE_FLAGS | _SPEC_FLAGS | _TEMPLATE_FLAGS
)
# Boolean flags inside _SHADOWING_FLAGS that take no value. The
# value-consuming heuristic in strip_shadowing_flags must skip just the
# flag for these, never the following token.
_BOOLEAN_SHADOWING_FLAGS: frozenset[str] = frozenset(
{"--spec-default", "--jinja", "--no-jinja"}
)
def strip_shadowing_flags(
args: Iterable[str],
*,
strip_context: bool = True,
strip_cache: bool = True,
strip_spec: bool = True,
strip_template: bool = True,
) -> list[str]:
"""Strip flags that shadow first-class Studio settings.
Used when the route inherits a previous load's ``llama_extra_args``
so that an inherited ``-c 4096`` cannot override the current
request's ``max_seq_length`` (and equivalents for cache /
speculative / chat template). Each ``strip_*`` flag controls one
group; the route only strips groups whose corresponding first-class
field was actually supplied by the caller, so an inherited
``--chat-template-file`` survives an Apply that omits both
``llama_extra_args`` and ``chat_template_override``.
"""
shadowing: set[str] = set()
if strip_context:
shadowing |= _CONTEXT_FLAGS
if strip_cache:
shadowing |= _CACHE_FLAGS
if strip_spec:
shadowing |= _SPEC_FLAGS
if strip_template:
shadowing |= _TEMPLATE_FLAGS
tokens = [str(a) for a in (args or [])]
out: list[str] = []
i, n = 0, len(tokens)
while i < n:
tok = tokens[i]
flag = _flag_name(tok)
if flag is None or flag not in shadowing:
out.append(tok)
i += 1
continue
# Drop this token. Boolean shadowing flags never carry a value;
# other shadowing flags consume the next token when it isn't a
# flag and the value isn't already packed as ``--key=value``.
if flag in _BOOLEAN_SHADOWING_FLAGS or "=" in tok:
i += 1
elif i + 1 < n and _flag_name(tokens[i + 1]) is None:
i += 2
else:
i += 1
return out

View file

@ -94,6 +94,38 @@ def _model_wants_causal_conv1d(model_name: str) -> bool:
)
def _hipcc_gcc_install_dir() -> str | None:
"""Return the highest-numbered ``/usr/lib/gcc/x86_64-linux-gnu/<N>`` that has
BOTH the gcc runtime dir AND the corresponding ``/usr/include/c++/<N>`` C++
headers, or ``None`` if no match (or non-Linux / non-x86_64).
Ubuntu 24.04 ships ``/usr/lib/gcc/x86_64-linux-gnu/14/`` (gcc-14 runtime
objects) but does NOT ship ``/usr/include/c++/14`` in its default apt set;
libstdc++ headers come from ``libstdc++-13-dev``. ROCm clang-20 picks the
highest-numbered runtime dir by default, finds no ``<cstdlib>``, and the
HIP source build fails with::
/opt/rocm-X.Y/lib/llvm/lib/clang/20/include/__clang_hip_runtime_wrapper.h:112:10:
fatal error: 'cstdlib' file not found
Returning a path lets the caller pass ``--gcc-install-dir=<path>`` to clang
via ``HIPCC_COMPILE_FLAGS_APPEND``. Mirrors the same loop ``bbf004c`` added
to ``studio/setup.sh`` for the llama.cpp HIP build branch (PR #5301).
"""
if not sys.platform.startswith("linux"):
return None
import platform as _platform
if _platform.machine().lower() != "x86_64":
return None
for _ver in (14, 13, 12, 11):
_runtime = f"/usr/lib/gcc/x86_64-linux-gnu/{_ver}/include"
_headers = f"/usr/include/c++/{_ver}"
if os.path.isdir(_runtime) and os.path.isdir(_headers):
return f"/usr/lib/gcc/x86_64-linux-gnu/{_ver}"
return None
def _install_package_wheel_first(
*,
event_queue: Any,
@ -229,6 +261,30 @@ def _install_package_wheel_first(
}
if is_hip:
_run_kwargs["timeout"] = 1800
# On Ubuntu 24.04 + ROCm clang-20, the HIP source build (causal-conv1d,
# mamba-ssm source fallback, flash-attn source fallback) defaults to
# /usr/lib/gcc/x86_64-linux-gnu/14/ which has the runtime dir but no
# /usr/include/c++/14 headers, and dies at:
# __clang_hip_runtime_wrapper.h:112:10:
# fatal error: 'cstdlib' file not found
# Inject --gcc-install-dir for a gcc whose C++ headers actually exist.
# Respect any pre-existing --gcc-install-dir in HIPCC_COMPILE_FLAGS_APPEND
# (user knows best); otherwise append. Mirrors the same fix bbf004c
# added to studio/setup.sh for the llama.cpp HIP build (PR #5301).
_existing_flags = os.environ.get("HIPCC_COMPILE_FLAGS_APPEND", "")
if "--gcc-install-dir" not in _existing_flags:
_gcc_dir = _hipcc_gcc_install_dir()
if _gcc_dir is not None:
_appended = (f"{_existing_flags} --gcc-install-dir={_gcc_dir}").strip()
_env = _run_kwargs.get("env", os.environ).copy()
_env["HIPCC_COMPILE_FLAGS_APPEND"] = _appended
_run_kwargs["env"] = _env
logger.info(
"HIP source build for %s: appended "
"--gcc-install-dir=%s to HIPCC_COMPILE_FLAGS_APPEND",
display_name,
_gcc_dir,
)
try:
result = _sp.run(pypi_cmd, **_run_kwargs)

View file

@ -119,7 +119,10 @@ try:
_DEFAULT_T_MAX_PREDICT_MS,
detect_reasoning_flags,
)
from core.inference.llama_server_args import validate_extra_args
from core.inference.llama_server_args import (
strip_shadowing_flags,
validate_extra_args,
)
from utils.models import ModelConfig
from utils.inference import load_inference_config
from utils.models.model_config import load_model_defaults
@ -141,7 +144,10 @@ except ImportError:
_DEFAULT_T_MAX_PREDICT_MS,
detect_reasoning_flags,
)
from core.inference.llama_server_args import validate_extra_args
from core.inference.llama_server_args import (
strip_shadowing_flags,
validate_extra_args,
)
from utils.models import ModelConfig
from utils.inference import load_inference_config
from utils.models.model_config import load_model_defaults
@ -406,6 +412,57 @@ def _validate_native_mmproj_companion(
) from exc
def _normalise_settings_str(value: Optional[str]) -> Optional[str]:
"""Lowercase + strip a settings string, mapping blank/None to None."""
if value is None:
return None
if isinstance(value, str):
stripped = value.strip().lower()
return stripped or None
return value
def _request_matches_loaded_settings(
request: LoadRequest, llama_backend: LlamaCppBackend
) -> bool:
"""True iff every runtime setting on the request matches the loaded
server. Caller has already checked model+variant+is_loaded. See #5401."""
# Compare requested n_ctx (not effective) so VRAM-cap doesn't mask
# an Auto-vs-explicit slider flip.
if request.max_seq_length != llama_backend.requested_n_ctx:
return False
if _normalise_settings_str(request.cache_type_kv) != _normalise_settings_str(
llama_backend.cache_type_kv
):
return False
# Vision loads silently drop speculative decoding (llama_cpp.py gates
# spec on ``not is_vision``), so treat the request as ``off`` against
# the backend's ``None`` to avoid forcing a redundant reload.
if llama_backend.is_vision:
req_spec = "off"
else:
req_spec = _normalise_settings_str(request.speculative_type) or "off"
backend_spec = _normalise_settings_str(llama_backend.speculative_type) or "off"
if req_spec != backend_spec:
return False
if (request.chat_template_override or None) != (
llama_backend.chat_template_override or None
):
return False
# llama_extra_args=None means "inherit"; only an explicit list that
# differs forces a reload. On the inherit path, refuse to match if
# stored extras contain any shadow flag, so the reload path can
# strip them instead of leaving a stale override in effect.
backend_extra = list(llama_backend.extra_args) if llama_backend.extra_args else []
if request.llama_extra_args is None:
if backend_extra and strip_shadowing_flags(backend_extra) != backend_extra:
return False
else:
if list(request.llama_extra_args) != backend_extra:
return False
return True
def _resolve_model_identifier_for_request(
request: LoadRequest | ValidateModelRequest,
*,
@ -461,6 +518,11 @@ async def load_model(
extra_llama_args = validate_extra_args(request.llama_extra_args)
except ValueError as exc:
raise HTTPException(status_code = 400, detail = str(exc))
# Re-narrow []-from-None back to None so the inheritance path
# below can tell "caller omitted" from "caller explicit []".
extra_llama_args: Optional[list[str]] = (
None if request.llama_extra_args is None else extra_llama_args
)
model_identifier, model_log_label, native_grant_backed = (
_resolve_model_identifier_for_request(request, operation = "load-model")
@ -479,6 +541,9 @@ async def load_model(
and llama_backend.hf_variant.lower() == request.gguf_variant.lower()
and llama_backend.model_identifier
and llama_backend.model_identifier.lower() == model_identifier.lower()
# Also require runtime settings to match so Apply changes
# aren't silently dropped (#5401).
and _request_matches_loaded_settings(request, llama_backend)
):
logger.info(
f"Model already loaded (GGUF): {model_log_label} variant={request.gguf_variant}, skipping reload"
@ -613,6 +678,70 @@ async def load_model(
)
unsloth_backend.unload_model(unsloth_backend.active_model_name)
# Inherit llama_extra_args from the previous load when the
# request omits the field (the chat-settings Apply path
# does not round-trip them; explicit [] still clears).
# Inheritance is gated on (model_identifier, hf_variant)
# to refuse cross-model pickup, and shadowing flags are
# stripped so an inherited override can't win the last-wins
# CLI parse against a freshly-supplied first-class field.
if request.llama_extra_args is None and llama_backend.extra_args:
source = llama_backend.extra_args_source
# Compare against the resolved variant, not the request
# field: callers commonly omit gguf_variant for local
# ``.gguf`` paths and HF auto-pick flows. ``config.gguf_
# variant`` is the variant load_model was actually
# invoked with (see the HF / local branches below), so
# both sides of the comparison key off the same string.
resolved_variant = config.gguf_variant
same_source = bool(
source
and source[0]
and source[0].lower() == model_identifier.lower()
and (source[1] or "").lower() == (resolved_variant or "").lower()
)
if not same_source:
logger.info(
"Not inheriting llama_extra_args: stored args came "
"from %s, loading %s",
source,
(model_identifier, resolved_variant),
)
# Cross-model: clear explicitly so the backend
# doesn't inherit via "no opinion" semantics.
extra_llama_args = []
else:
# Strip only the groups whose first-class field
# was actually set by the caller, so an inherited
# --chat-template-file survives an Apply that omits
# chat_template_override.
fields_set = getattr(request, "model_fields_set", set())
stripped = strip_shadowing_flags(
llama_backend.extra_args,
strip_context = "max_seq_length" in fields_set,
strip_cache = "cache_type_kv" in fields_set,
strip_spec = "speculative_type" in fields_set,
strip_template = "chat_template_override" in fields_set,
)
try:
extra_llama_args = validate_extra_args(stripped)
except ValueError:
# Should not happen on already-validated args; degrade
# to no-extras rather than 400 if managed flags changed.
logger.warning(
"Stored llama_extra_args failed revalidation; "
"loading without them: %s",
stripped,
)
extra_llama_args = []
else:
if extra_llama_args:
logger.info(
"Inheriting llama_extra_args from previous "
"load (same model, shadow-stripped): %s",
extra_llama_args,
)
# Route to HF mode or local mode based on config
# Run in a thread so the event loop stays free for progress
# polling and other requests during the (potentially long)
@ -645,6 +774,10 @@ async def load_model(
llama_backend.load_model,
gguf_path = config.gguf_file,
mmproj_path = config.gguf_mmproj_file,
# Pass the resolved variant so _extra_args_source
# is keyed off the same string the inheritance
# check at the top of /load uses (#5401 followup).
hf_variant = config.gguf_variant,
model_identifier = config.identifier,
is_vision = config.is_vision,
n_ctx = request.max_seq_length,

View file

@ -0,0 +1,237 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Backend contract for the GGUF reload duplicate-load guard.
``LlamaCppBackend._already_in_target_state`` is the in-process
short-circuit that prevents a serialised duplicate /load from killing
the just-spawned llama-server. These tests pin the local-file
identity, the HF-mode hf_variant fallback, and the ``extra_args``
None-vs-[] inherit semantics so the guard cannot silently regress.
"""
from __future__ import annotations
import sys
import types as _types
from pathlib import Path
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
_structlog_stub = _types.ModuleType("structlog")
_structlog_stub.get_logger = lambda *a, **k: __import__("logging").getLogger("stub")
sys.modules.setdefault("structlog", _structlog_stub)
_httpx_stub = _types.ModuleType("httpx")
for _exc in (
"ConnectError",
"TimeoutException",
"ReadTimeout",
"ReadError",
"RemoteProtocolError",
"CloseError",
):
setattr(_httpx_stub, _exc, type(_exc, (Exception,), {}))
_httpx_stub.Timeout = type("T", (), {"__init__": lambda s, *a, **k: None})
_httpx_stub.Client = type(
"C",
(),
{
"__init__": lambda s, **kw: None,
"__enter__": lambda s: s,
"__exit__": lambda s, *a: None,
},
)
sys.modules.setdefault("httpx", _httpx_stub)
from core.inference.llama_cpp import LlamaCppBackend
class _FakeProcess:
"""Stand-in for subprocess.Popen so atexit cleanup doesn't crash."""
def terminate(self):
pass
def wait(self, timeout = None):
return 0
def kill(self):
pass
def poll(self):
return 0
def _loaded_backend(**overrides):
backend = LlamaCppBackend()
backend._process = _FakeProcess() # is_loaded only checks "is not None"
backend._healthy = True
backend._model_identifier = "owner/repo"
backend._hf_variant = "Q4_K_M"
backend._requested_n_ctx = 8192
backend._cache_type_kv = None
backend._speculative_type = None
backend._chat_template_override = None
backend._is_vision = False
backend._extra_args = None
backend._extra_args_source = None
backend._gguf_path = None
for key, value in overrides.items():
setattr(backend, key, value)
return backend
# ── Local-file identity via gguf_path ────────────────────────────────
def test_already_in_target_state_uses_gguf_path_when_present(tmp_path):
gguf_file = tmp_path / "model.Q4_K_M.gguf"
gguf_file.write_bytes(b"")
backend = _loaded_backend(
_hf_variant = "Q4_K_M",
_gguf_path = str(gguf_file),
)
assert (
backend._already_in_target_state(
gguf_path = str(gguf_file),
model_identifier = "owner/repo",
hf_variant = None,
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = None,
is_vision = False,
)
is True
)
def test_already_in_target_state_rejects_different_gguf_path(tmp_path):
a = tmp_path / "a.gguf"
a.write_bytes(b"")
b = tmp_path / "b.gguf"
b.write_bytes(b"")
backend = _loaded_backend(_gguf_path = str(a))
assert (
backend._already_in_target_state(
gguf_path = str(b),
model_identifier = "owner/repo",
hf_variant = None,
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = None,
is_vision = False,
)
is False
)
# ── HF mode falls back to hf_variant comparison ──────────────────────
def test_already_in_target_state_falls_back_to_hf_variant_for_hf_loads():
backend = _loaded_backend(_hf_variant = "Q4_K_M", _gguf_path = None)
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "owner/repo",
hf_variant = "Q8_0",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = None,
is_vision = False,
)
is False
)
def test_already_in_target_state_hf_same_variant_matches():
backend = _loaded_backend(_hf_variant = "Q4_K_M", _gguf_path = None)
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "owner/repo",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = None,
is_vision = False,
)
is True
)
# ── extra_args: None inherits, [] forces reload, list enforces ───────
def test_already_in_target_state_none_extras_inherits_stored():
backend = _loaded_backend(_extra_args = ["--top-k", "20"])
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "owner/repo",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = None,
is_vision = False,
)
is True
)
def test_already_in_target_state_empty_extras_forces_reload_when_stored():
backend = _loaded_backend(_extra_args = ["--top-k", "20"])
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "owner/repo",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = [],
is_vision = False,
)
is False
)
def test_already_in_target_state_explicit_extras_match():
backend = _loaded_backend(_extra_args = ["--top-k", "20"])
assert (
backend._already_in_target_state(
gguf_path = None,
model_identifier = "owner/repo",
hf_variant = "Q4_K_M",
n_ctx = 8192,
cache_type_kv = None,
speculative_type = None,
chat_template_override = None,
extra_args = ["--top-k", "20"],
is_vision = False,
)
is True
)
def test_extra_args_source_default_is_none():
backend = LlamaCppBackend()
assert backend.extra_args_source is None

View file

@ -15,6 +15,7 @@ import pytest
from core.inference.llama_server_args import (
is_managed_flag,
strip_shadowing_flags,
validate_extra_args,
)
@ -187,3 +188,120 @@ def test_is_managed_flag_false_for_pass_through():
assert is_managed_flag("--flash-attn") is False
assert is_managed_flag("-ngl") is False
assert is_managed_flag("--threads") is False
# ── strip_shadowing_flags ─────────────────────────────────────────────
def test_strip_shadowing_flags_drops_context_when_requested():
out = strip_shadowing_flags(
["-c", "4096", "--top-k", "20"],
strip_context = True,
strip_cache = False,
strip_spec = False,
strip_template = False,
)
assert out == ["--top-k", "20"]
def test_strip_shadowing_flags_keeps_context_when_not_requested():
out = strip_shadowing_flags(
["-c", "4096", "--top-k", "20"],
strip_context = False,
strip_cache = False,
strip_spec = False,
strip_template = False,
)
assert out == ["-c", "4096", "--top-k", "20"]
def test_strip_shadowing_flags_keeps_chat_template_when_template_disabled():
# Caller did not supply chat_template_override; the inherited
# --chat-template-file must survive the strip.
out = strip_shadowing_flags(
["--chat-template-file", "/tmp/custom.jinja", "--top-k", "20"],
strip_context = True,
strip_cache = True,
strip_spec = True,
strip_template = False,
)
assert out == ["--chat-template-file", "/tmp/custom.jinja", "--top-k", "20"]
def test_strip_shadowing_flags_drops_template_when_requested():
out = strip_shadowing_flags(
["--chat-template-file", "/tmp/custom.jinja", "--top-k", "20"],
strip_template = True,
)
assert out == ["--top-k", "20"]
def test_strip_shadowing_flags_keeps_cache_when_cache_disabled():
out = strip_shadowing_flags(
["--cache-type-k", "q8_0", "--cache-type-v", "q8_0", "--top-k", "20"],
strip_cache = False,
)
assert out == [
"--cache-type-k",
"q8_0",
"--cache-type-v",
"q8_0",
"--top-k",
"20",
]
def test_strip_shadowing_flags_keeps_spec_when_spec_disabled():
out = strip_shadowing_flags(
["--spec-type", "ngram-mod", "--draft-min", "48", "--top-k", "20"],
strip_spec = False,
)
assert out == [
"--spec-type",
"ngram-mod",
"--draft-min",
"48",
"--top-k",
"20",
]
def test_strip_shadowing_flags_boolean_does_not_consume_next_token():
# --spec-default is a boolean shadowing flag; the value-skipping
# heuristic must skip just the flag, not the following positional.
out = strip_shadowing_flags(["--spec-default", "ngram-mod"], strip_spec = True)
assert out == ["ngram-mod"]
def test_strip_shadowing_flags_jinja_boolean_preserves_positional():
out = strip_shadowing_flags(["--jinja", "trailing-positional"], strip_template = True)
assert out == ["trailing-positional"]
def test_strip_shadowing_flags_no_jinja_boolean_preserves_positional():
out = strip_shadowing_flags(
["--no-jinja", "trailing-positional"], strip_template = True
)
assert out == ["trailing-positional"]
def test_strip_shadowing_flags_equals_form_drops_only_the_flag():
out = strip_shadowing_flags(["--ctx-size=4096", "--seed", "-1"], strip_context = True)
assert out == ["--seed", "-1"]
def test_strip_shadowing_flags_handles_none_input():
assert strip_shadowing_flags(None) == []
def test_strip_shadowing_flags_handles_empty_input():
assert strip_shadowing_flags([]) == []
def test_strip_shadowing_flags_defaults_strip_everything():
# The route's already-loaded comparator calls strip_shadowing_flags
# with no kwargs to detect ANY shadowing flag in stored extras.
out = strip_shadowing_flags(
["-c", "4096", "--cache-type-k", "q8_0", "--spec-default", "--jinja"]
)
assert out == []

View file

@ -6,6 +6,7 @@ from __future__ import annotations
import builtins
import subprocess
import sys
from typing import Any
from unittest import mock
from core.training import worker
@ -22,6 +23,17 @@ def _missing_flash_attn_import():
return fake_import
def _missing_module_import(missing: str):
real_import = builtins.__import__
def fake_import(name, globals = None, locals = None, fromlist = (), level = 0):
if name == missing:
raise ImportError
return real_import(name, globals, locals, fromlist, level)
return fake_import
def test_should_try_runtime_flash_attn_install_threshold_and_skip(monkeypatch):
monkeypatch.delenv(worker._FLASH_ATTN_SKIP_ENV, raising = False)
assert worker._should_try_runtime_flash_attn_install(32767) is False
@ -1490,3 +1502,270 @@ def test_model_wants_tilelang_normalizes_separators(monkeypatch):
"qwen3.next-80b",
):
assert worker._model_wants_tilelang(variant) is True, variant
# ────────────────────────────────────────────────────────────────────
# HIP source-build gcc-install-dir coverage (h34v3nzc0dex Strix Halo).
# Ubuntu 24.04 ships gcc-14's runtime dir without /usr/include/c++/14,
# so ROCm clang-20 picks it and fails with 'cstdlib' file not found
# when building causal-conv1d (or any other HIP source fallback).
# _hipcc_gcc_install_dir() finds a gcc dir that has both halves; the
# _install_package_wheel_first HIP branch passes it to clang via
# HIPCC_COMPILE_FLAGS_APPEND. Parallel to bbf004c's setup.sh fix for
# the llama.cpp HIP build (PR #5301).
# ────────────────────────────────────────────────────────────────────
def _isdir_for_layout(*existing: str):
"""Return an os.path.isdir replacement that only treats the given
absolute paths as directories. Lets a test simulate exactly which
gcc runtime dirs and C++ header dirs exist on the host."""
valid = set(existing)
def fake_isdir(path: str) -> bool:
return path in valid
return fake_isdir
def test_hipcc_gcc_install_dir_picks_highest_with_headers(monkeypatch):
"""gcc-14 has runtime but no /usr/include/c++/14; loop falls through
to gcc-13 which has both. This is the exact Ubuntu 24.04 layout."""
monkeypatch.setattr(sys, "platform", "linux")
import platform as _platform
monkeypatch.setattr(_platform, "machine", lambda: "x86_64")
monkeypatch.setattr(
worker.os.path,
"isdir",
_isdir_for_layout(
"/usr/lib/gcc/x86_64-linux-gnu/14/include", # runtime present
# but no /usr/include/c++/14 — typical Ubuntu 24.04 default
"/usr/lib/gcc/x86_64-linux-gnu/13/include",
"/usr/include/c++/13", # libstdc++-13-dev installed
),
)
assert worker._hipcc_gcc_install_dir() == "/usr/lib/gcc/x86_64-linux-gnu/13"
def test_hipcc_gcc_install_dir_picks_14_when_headers_exist(monkeypatch):
"""If the user has libstdc++-14-dev installed, prefer gcc-14."""
monkeypatch.setattr(sys, "platform", "linux")
import platform as _platform
monkeypatch.setattr(_platform, "machine", lambda: "x86_64")
monkeypatch.setattr(
worker.os.path,
"isdir",
_isdir_for_layout(
"/usr/lib/gcc/x86_64-linux-gnu/14/include",
"/usr/include/c++/14",
),
)
assert worker._hipcc_gcc_install_dir() == "/usr/lib/gcc/x86_64-linux-gnu/14"
def test_hipcc_gcc_install_dir_returns_none_when_no_match(monkeypatch):
"""No gcc dir has both halves → return None and skip the env injection
rather than guessing wrong and surfacing a confusing build failure."""
monkeypatch.setattr(sys, "platform", "linux")
import platform as _platform
monkeypatch.setattr(_platform, "machine", lambda: "x86_64")
monkeypatch.setattr(worker.os.path, "isdir", lambda path: False)
assert worker._hipcc_gcc_install_dir() is None
def test_hipcc_gcc_install_dir_returns_none_on_non_linux(monkeypatch):
"""Don't probe gcc layout on macOS / Windows — early-return."""
monkeypatch.setattr(sys, "platform", "darwin")
def _isdir_should_not_be_called(_path):
raise AssertionError("isdir should not be called on non-Linux")
monkeypatch.setattr(worker.os.path, "isdir", _isdir_should_not_be_called)
assert worker._hipcc_gcc_install_dir() is None
def test_hipcc_gcc_install_dir_returns_none_on_non_x86_64(monkeypatch):
"""ROCm clang-20 on aarch64 has a different libstdc++ layout."""
monkeypatch.setattr(sys, "platform", "linux")
import platform as _platform
monkeypatch.setattr(_platform, "machine", lambda: "aarch64")
assert worker._hipcc_gcc_install_dir() is None
def _make_hip_install_env(monkeypatch, *, gcc_dir: str | None):
"""Common scaffolding for tests that exercise the HIP source-build
branch of _install_package_wheel_first end-to-end. The package isn't
installed yet, no prebuilt wheel exists, hipcc is on PATH, and the
fake env reports an HIP torch."""
monkeypatch.setattr(builtins, "__import__", _missing_module_import("causal_conv1d"))
monkeypatch.setattr(
worker,
"probe_torch_wheel_env",
lambda timeout = 30: {
"hip_version": "7.13.26176",
"python_tag": "cp312",
"torch_mm": "2.11",
"cxx11abi": "TRUE",
"platform_tag": "linux_x86_64",
},
)
monkeypatch.setattr(worker, "direct_wheel_url", lambda **kw: None)
monkeypatch.setattr(
worker.shutil,
"which",
lambda name: "/opt/rocm/bin/hipcc" if name == "hipcc" else None,
)
monkeypatch.setattr(worker, "_send_status", lambda *a, **k: None)
monkeypatch.setattr(worker, "_hipcc_gcc_install_dir", lambda: gcc_dir)
def test_install_injects_gcc_install_dir_on_hip_source_build(monkeypatch):
"""HIP source-build with no user-set HIPCC_COMPILE_FLAGS_APPEND →
subprocess env carries --gcc-install-dir=<detected path>."""
monkeypatch.delenv("HIPCC_COMPILE_FLAGS_APPEND", raising = False)
_make_hip_install_env(monkeypatch, gcc_dir = "/usr/lib/gcc/x86_64-linux-gnu/13")
captured: dict[str, str] = {}
def fake_run(cmd, **kwargs):
captured.update(kwargs.get("env") or {})
return subprocess.CompletedProcess(cmd, 0, "")
monkeypatch.setattr(worker._sp, "run", fake_run)
worker._install_package_wheel_first(
event_queue = [],
import_name = "causal_conv1d",
display_name = "causal-conv1d",
pypi_name = "causal-conv1d",
pypi_version = "1.6.2.post1",
filename_prefix = "causal_conv1d",
release_tag = "v1.6.2.post1",
release_base_url = "https://example.com",
)
assert (
captured.get("HIPCC_COMPILE_FLAGS_APPEND")
== "--gcc-install-dir=/usr/lib/gcc/x86_64-linux-gnu/13"
)
def test_install_appends_to_existing_hipcc_compile_flags(monkeypatch):
"""User has HIPCC_COMPILE_FLAGS_APPEND='-O3 -DFOO' set → final value
keeps the user's flags AND adds --gcc-install-dir at the end."""
monkeypatch.setenv("HIPCC_COMPILE_FLAGS_APPEND", "-O3 -DFOO")
_make_hip_install_env(monkeypatch, gcc_dir = "/usr/lib/gcc/x86_64-linux-gnu/13")
captured: dict[str, str] = {}
def fake_run(cmd, **kwargs):
captured.update(kwargs.get("env") or {})
return subprocess.CompletedProcess(cmd, 0, "")
monkeypatch.setattr(worker._sp, "run", fake_run)
worker._install_package_wheel_first(
event_queue = [],
import_name = "causal_conv1d",
display_name = "causal-conv1d",
pypi_name = "causal-conv1d",
pypi_version = "1.6.2.post1",
filename_prefix = "causal_conv1d",
release_tag = "v1.6.2.post1",
release_base_url = "https://example.com",
)
assert captured.get("HIPCC_COMPILE_FLAGS_APPEND") == (
"-O3 -DFOO --gcc-install-dir=/usr/lib/gcc/x86_64-linux-gnu/13"
)
def test_install_respects_user_gcc_install_dir(monkeypatch):
"""User explicitly set --gcc-install-dir=… already → don't touch it.
Avoids two competing --gcc-install-dir flags on the clang command line."""
monkeypatch.setenv(
"HIPCC_COMPILE_FLAGS_APPEND",
"--gcc-install-dir=/opt/custom/gcc-13",
)
_make_hip_install_env(monkeypatch, gcc_dir = "/usr/lib/gcc/x86_64-linux-gnu/13")
captured: dict[str, str] | None = {"_called": "no"}
def fake_run(cmd, **kwargs):
env = kwargs.get("env")
if env is not None:
captured.clear()
captured.update(env)
else:
captured["_called"] = "yes_no_env"
return subprocess.CompletedProcess(cmd, 0, "")
monkeypatch.setattr(worker._sp, "run", fake_run)
worker._install_package_wheel_first(
event_queue = [],
import_name = "causal_conv1d",
display_name = "causal-conv1d",
pypi_name = "causal-conv1d",
pypi_version = "1.6.2.post1",
filename_prefix = "causal_conv1d",
release_tag = "v1.6.2.post1",
release_base_url = "https://example.com",
)
# subprocess.run was invoked without env override (the user already
# set HIPCC_COMPILE_FLAGS_APPEND with --gcc-install-dir, so we left
# the env alone — the existing value is inherited normally).
assert captured == {"_called": "yes_no_env"}
def test_install_does_not_inject_env_on_cuda(monkeypatch):
"""CUDA path (no hip_version in env) → no env override at all."""
monkeypatch.delenv("HIPCC_COMPILE_FLAGS_APPEND", raising = False)
monkeypatch.setattr(builtins, "__import__", _missing_module_import("causal_conv1d"))
monkeypatch.setattr(
worker,
"probe_torch_wheel_env",
lambda timeout = 30: {
"python_tag": "cp312",
"torch_mm": "2.11",
"cuda_major": "12",
"cxx11abi": "TRUE",
"platform_tag": "linux_x86_64",
},
)
monkeypatch.setattr(worker, "direct_wheel_url", lambda **kw: None)
monkeypatch.setattr(worker.shutil, "which", lambda name: None)
monkeypatch.setattr(worker, "_send_status", lambda *a, **k: None)
# If _hipcc_gcc_install_dir were called on CUDA we'd want to know.
monkeypatch.setattr(
worker,
"_hipcc_gcc_install_dir",
lambda: (_ for _ in ()).throw(AssertionError("must not run on CUDA")),
)
captured: dict[str, Any] = {}
def fake_run(cmd, **kwargs):
captured["env_in_kwargs"] = "env" in kwargs
return subprocess.CompletedProcess(cmd, 0, "")
monkeypatch.setattr(worker._sp, "run", fake_run)
worker._install_package_wheel_first(
event_queue = [],
import_name = "causal_conv1d",
display_name = "causal-conv1d",
pypi_name = "causal-conv1d",
pypi_version = "1.6.2.post1",
filename_prefix = "causal_conv1d",
release_tag = "v1.6.2.post1",
release_base_url = "https://example.com",
)
# CUDA branch never sets the env, never invokes the gcc helper.
assert captured.get("env_in_kwargs") is False

View file

@ -328,6 +328,9 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
autoFocus={!disabled}
disabled={disabled}
aria-label="Message input"
// dir="auto": browser picks LTR/RTL from the first strong char;
// no effect on Latin / CJK / Devanagari.
dir="auto"
{...inputProps}
/>
<ComposerAction
@ -1161,6 +1164,8 @@ const EditComposer: FC = () => {
<ComposerPrimitive.Input
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm font-[450] outline-none"
autoFocus={true}
// See main composer above for the dir="auto" rationale.
dir="auto"
{...inputProps}
/>
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">

View file

@ -690,6 +690,9 @@ export function SharedComposer({
placeholder="Send to both models..."
className="composer-input"
rows={1}
// dir="auto" auto-detects RTL (Arabic / Hebrew / Persian / Urdu)
// from the first strong character; no effect on LTR scripts.
dir="auto"
/>
<div className="composer-action-wrapper">
<div className="flex items-center gap-1">

View file

@ -0,0 +1,457 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Studio chat composer IME + multilingual regression smoke.
Covers two 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.
Model-free; the bug surface is the composer, not inference.
Env contract matches playwright_chat_ui.py:
BASE_URL, STUDIO_NEW_PW, PW_ART_DIR, STUDIO_UI_STRICT.
"""
import os
import sys
from pathlib import Path
from playwright.sync_api import expect, sync_playwright
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _playwright_robust import ( # noqa: E402
chromium_launch_args,
click_and_wait_for_response,
install_view_transition_killer,
install_wall_clock_watchdog,
is_benign_console_error,
is_benign_page_error,
recover_or_replace_page,
wait_for_health,
)
BASE = os.environ["BASE_URL"]
NEW = os.environ["STUDIO_NEW_PW"]
ART_DIR = os.environ.get("PW_ART_DIR", "logs/playwright_ime")
ART = Path(ART_DIR)
ART.mkdir(parents = True, exist_ok = True)
STRICT = os.environ.get("STUDIO_UI_STRICT", "0") == "1"
# Wall-clock cap. Realistic run is 30-60s; 5 min leaves cold-launch headroom.
WALL_TIMEOUT_S = float(os.environ.get("STUDIO_IME_WALL_TIMEOUT_S", "300"))
# One short greeting + arithmetic per script (ordered by speaker count) --
# each entry catches a distinct class of Unicode regression.
I18N_SAMPLES = [
("en", "English", "Hello, 1+1=2"),
("zh-CN", "Chinese (Simplified)", "你好1+1=2"),
("es", "Spanish", "Hola, 1+1=2"),
("hi", "Hindi (Devanagari)", "नमस्ते, 1+1=2"),
("ar", "Arabic (RTL)", "مرحبا، ١+١"),
("bn", "Bengali", "নমস্কার, ১+১=২"),
("pt", "Portuguese", "Olá, 1+1=2"),
("ru", "Russian (Cyrillic)", "Привет, 1+1=2"),
("ja", "Japanese", "こんにちは、1+1=2"),
("pa", "Punjabi (Gurmukhi)", "ਸਤ ਸ੍ਰੀ ਅਕਾਲ, 1+1=2"),
("de", "German", "Hallo, 1+1=2"),
("jv", "Javanese", "Halo, 1+1=2"),
("ko", "Korean (Hangul)", "안녕하세요, 1+1=2"),
("fr", "French", "Bonjour, 1+1=2"),
("tr", "Turkish", "Merhaba, 1+1=2"),
("vi", "Vietnamese (diacritics)", "Xin chào, 1+1=2"),
("ur", "Urdu (Arabic-Naskh)", "ہیلو، 1+1=2"),
("ta", "Tamil", "வணக்கம், 1+1=2"),
("te", "Telugu", "నమస్తే, 1+1=2"),
("mr", "Marathi (Devanagari)", "नमस्कार, 1+1=2"),
("it", "Italian", "Ciao, 1+1=2"),
("th", "Thai", "สวัสดี, ๑+๑=๒"),
("pl", "Polish", "Cześć, 1+1=2"),
("uk", "Ukrainian (Cyrillic)", "Привіт, 1+1=2"),
("fa", "Persian (RTL)", "سلام، ۱+۱"),
("nl", "Dutch", "Hallo, 1+1=2"),
("he", "Hebrew (RTL)", "שלום, 1+1=2"),
("el", "Greek", "Γειά, 1+1=2"),
("id", "Indonesian", "Halo, 1+1=2"),
("sw", "Swahili", "Habari, 1+1=2"),
("emoji", "Emoji + ZWJ + flag", "👋 🇺🇳 👨‍👩‍👧‍👦 1+1=2"),
]
_n = [0]
def step(s):
print(f"[ime] STEP {s}", flush = True)
def info(s):
print(f"[ime] {s}", flush = True)
def fail(m):
raise AssertionError(f"[ime] FAIL: {m}")
def soft_fail(m):
"""Hard fail in STRICT mode, info-warn otherwise. Mirrors playwright_chat_ui.py."""
if STRICT:
fail(m)
info(f"WARN (strict-off): {m}")
with sync_playwright() as p:
_watchdog = install_wall_clock_watchdog(
WALL_TIMEOUT_S,
label = "ime",
info = info,
)
wait_for_health(BASE, timeout = 30.0, info = info)
browser = p.chromium.launch(
headless = True,
args = chromium_launch_args(),
)
ctx = browser.new_context(
viewport = {"width": 1280, "height": 900},
reduced_motion = "reduce",
)
install_view_transition_killer(ctx)
page = ctx.new_page()
page.set_default_timeout(60_000)
page_errors: list[str] = []
console_errors: list[str] = []
def _on_console(m):
if m.type != "error":
return
try:
console_errors.append(m.text)
except Exception:
return
def _attach_listeners(target):
target.on("pageerror", lambda e: page_errors.append(str(e)))
target.on("console", _on_console)
_attach_listeners(page)
def shoot(name):
_n[0] += 1
try:
page.screenshot(
path = str(ART / f"{_n[0]:02d}-{name}.png"),
full_page = True,
timeout = 90_000,
animations = "disabled",
)
except Exception as _shoot_err:
info(f"WARN: screenshot {name} failed: {_shoot_err}")
# 1. Bootstrap auth via /change-password (mirrors playwright_chat_ui.py
# retry-on-rerender to absorb React form-detach races).
step("change-password through UI (Setup your account)")
form_err: Exception | None = None
for _form_attempt in range(3):
try:
page.goto(
f"{BASE}/change-password",
wait_until = "domcontentloaded",
timeout = 60_000,
)
try:
page.wait_for_load_state("networkidle", timeout = 30_000)
except Exception:
pass
pw_field = page.locator("#new-password")
pw_field.wait_for(state = "visible", timeout = 60_000)
pw_field.fill(NEW, timeout = 60_000)
page.fill("#confirm-password", NEW, timeout = 60_000)
shoot("01-change-password-filled")
status, _ = click_and_wait_for_response(
page,
url_substr = "/api/auth/change-password",
method = "POST",
do_click = lambda: page.locator('button[type="submit"]').click(),
timeout_ms = 30_000,
info = lambda m: print(f"[ime] {m}", flush = True),
)
if status is not None and status >= 400:
raise AssertionError(f"change-password POST returned {status}")
form_err = None
break
except Exception as e:
form_err = e
info(
f"change-password attempt {_form_attempt + 1} failed: "
f"{type(e).__name__}: {str(e)[:200]}"
)
if _form_attempt < 2:
page = recover_or_replace_page(
page,
ctx,
default_timeout_ms = 60_000,
info = lambda m: print(f"[ime] recovery: {m}", flush = True),
)
_attach_listeners(page)
if form_err is not None:
raise form_err
# 2. Wait for composer mount. No GGUF: the bug surface is React state, not inference.
step("wait for composer to mount")
try:
page.wait_for_load_state("networkidle", timeout = 30_000)
except Exception:
pass
composer = page.locator('textarea[aria-label="Message input"]')
_mount_err: Exception | None = None
for _mount_attempt in range(2):
try:
composer.wait_for(state = "visible", timeout = 60_000)
_mount_err = None
break
except Exception as e:
_mount_err = e
info(
f"composer.wait_for attempt {_mount_attempt + 1} failed: "
f"{type(e).__name__}: {str(e)[:200]}"
)
try:
shoot(f"02-composer-wait-attempt-{_mount_attempt + 1}-fail")
except Exception:
pass
if _mount_attempt == 0:
page = recover_or_replace_page(
page,
ctx,
default_timeout_ms = 60_000,
info = lambda m: print(f"[ime] recovery: {m}", flush = True),
)
_attach_listeners(page)
composer = page.locator('textarea[aria-label="Message input"]')
if _mount_err is not None:
raise _mount_err
composer.click()
shoot("02-composer-focused")
# Main composer must carry dir="auto" so RTL flows right-to-left.
dir_attr = composer.evaluate("(el) => el.getAttribute('dir')")
if dir_attr != "auto":
soft_fail(
f'composer is missing dir="auto" (got {dir_attr!r}); RTL '
"languages will render LTR."
)
else:
info('composer dir="auto" present')
# Source-level guard for the edit and compare composers (neither
# is mounted here): grep the JSX for dir="auto" inside each block.
_repo_root = Path(__file__).resolve().parents[2]
_thread_src = (
_repo_root / "studio/frontend/src/components/assistant-ui/thread.tsx"
).read_text()
_shared_src = (
_repo_root / "studio/frontend/src/features/chat/shared-composer.tsx"
).read_text()
_edit_idx = _thread_src.find("aui-edit-composer-input")
if _edit_idx == -1 or 'dir="auto"' not in _thread_src[_edit_idx : _edit_idx + 600]:
soft_fail('edit composer source is missing dir="auto"')
else:
info('edit composer dir="auto" present (source)')
_compare_idx = _shared_src.find("Send to both models")
if (
_compare_idx == -1
or 'dir="auto"'
not in _shared_src[max(_compare_idx - 400, 0) : _compare_idx + 400]
):
soft_fail('compare composer source is missing dir="auto"')
else:
info('compare composer dir="auto" present (source)')
def read_value() -> str:
return composer.evaluate("(el) => el.value")
def set_value_via_setter(s: str) -> str:
"""Write via React's monkey-patched setter + paste input event,
then await two rAFs so the controlled value is committed before
readback (plain `.value=s` would be overwritten on next render)."""
return composer.evaluate(
"""async (el, v) => {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype, 'value'
).set;
setter.call(el, v);
el.dispatchEvent(new InputEvent('input', {
bubbles: true,
inputType: 'insertFromPaste',
data: v,
}));
await new Promise((r) => requestAnimationFrame(r));
await new Promise((r) => requestAnimationFrame(r));
return el.value;
}""",
s,
)
def clear() -> None:
set_value_via_setter("")
# 3. Baseline: ASCII keyboard typing works. Bail fast if not.
step("baseline ASCII keyboard typing")
clear()
composer.click()
for ch in "hello world":
page.keyboard.type(ch)
got = read_value()
if got != "hello world":
fail(f"ASCII typing readback {got!r} != 'hello world'")
info("baseline ASCII OK")
shoot("03-baseline-ascii")
clear()
# 4. Multilingual paste round-trip; byte-for-byte readback required.
step(f"multilingual paste round-trip ({len(I18N_SAMPLES)} samples)")
paste_failures: list[tuple[str, str, str, str]] = []
for code, label, text in I18N_SAMPLES:
got = set_value_via_setter(text)
if got != text:
paste_failures.append((code, label, text, got))
info(f" {code:>6} ({label}): FAIL -- got {got!r}")
else:
info(f" {code:>6} ({label}): OK")
clear()
if paste_failures:
shoot("04-paste-failures")
lines = [
f" {code} ({label}): want={want!r} got={got!r}"
for code, label, want, got in paste_failures
]
fail(
f"{len(paste_failures)}/{len(I18N_SAMPLES)} languages failed paste round-trip:\n"
+ "\n".join(lines)
)
info(f"all {len(I18N_SAMPLES)} multilingual paste samples OK")
shoot("04-paste-all-ok")
# 5. Healthy IME composition (compositionstart/update/end + insert events).
step("normal IME composition (compose 你好)")
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,
}));
el.dispatchEvent(new CompositionEvent('compositionend', {bubbles:true, data:'你好'}));
el.dispatchEvent(new InputEvent('input', {
bubbles:true, inputType:'insertFromComposition', data:'你好',
}));
}"""
)
got = read_value()
if "你好" not in got:
shoot("05-normal-composition-FAIL")
fail(f"normal composition readback {got!r} missing '你好'")
info(f"normal composition OK: ta.value={got!r}")
shoot("05-normal-composition")
clear()
# 6. Stuck IME repro for issue #5318: duplicate compositionstart with
# no compositionend wedged isComposing=true and dropped ASCII keys.
# PR #5327 cleared the stale state on non-composing input.
step("BUG REPRO: stuck IME composition recovery (issue #5318)")
clear()
composer.click()
composer.evaluate(
"""(el) => {
el.focus();
el.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true, data:''}));
// Duplicate compositionstart with NO matching compositionend.
// This is exactly the event sequence observed from the IMEs
// in issue #5318 (kei-yamazaki / langxiaopiao030 / PapyrusNotes).
el.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true, data:''}));
}"""
)
# Drive the real keyboard path; on the broken build React drops
# 'abcd' and reconciles el.value back to ''. wait_for_function
# crosses the microtask boundary so we see committed React state.
page.keyboard.type("abcd")
try:
page.wait_for_function(
"""(el) => el.value === 'abcd'""",
composer.element_handle(),
timeout = 5_000,
)
except Exception:
pass
after_key = read_value()
info(f"after_key='abcd' readback={after_key!r}")
shoot("06-stuck-composition-recovery")
if after_key != "abcd":
fail(
"stuck-composition repro: keyboard 'abcd' was not preserved after "
f"duplicate compositionstart; readback {after_key!r}. React state "
"likely still stuck in isComposing=true (issue #5318 / before "
"PR #5327)."
)
# Cross-check React's view of isComposing via the Send button:
# ComposerAction stays disabled while isComposing is true (PR #5327).
send_btn = page.locator('button[aria-label="Send message"]')
if send_btn.count() == 0:
soft_fail("Send button not found after stuck-composition recovery")
else:
try:
expect(send_btn).not_to_be_disabled(timeout = 5_000)
info("Send button correctly enabled after stuck-composition recovery")
except Exception:
soft_fail(
"Send button still disabled after stuck-composition recovery -- "
"React isComposing state likely never cleared"
)
info("stuck-composition recovery 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")
real_page_errors = [e for e in page_errors if not is_benign_page_error(e)]
real_console_errors = [e for e in console_errors if not is_benign_console_error(e)]
info(
f"page_errors={len(page_errors)} ({len(real_page_errors)} non-benign); "
f"console_errors={len(console_errors)} "
f"({len(real_console_errors)} non-benign)"
)
if page_errors:
info(f"first page error: {page_errors[0][:200]!r}")
if console_errors:
info(f"first console error: {console_errors[0][:200]!r}")
if real_page_errors:
fail(
f"{len(real_page_errors)} non-benign pageerror events; "
f"first={real_page_errors[0][:200]!r}"
)
if real_console_errors:
fail(
f"{len(real_console_errors)} non-benign console.error events; "
f"first={real_console_errors[0][:200]!r}"
)
info(
f"DONE: ascii=OK paste={len(I18N_SAMPLES)}/{len(I18N_SAMPLES)} "
f"normal_composition=OK stuck_recovery=OK"
)
_watchdog.cancel()
browser.close()

View file

@ -0,0 +1,73 @@
"""Lock down the RTL bidi auto-detection contract on the chat composers.
The browser's Unicode bidi algorithm only flows Arabic / Hebrew / Persian /
Urdu right-to-left when the textarea carries `dir="auto"`. The three
composer surfaces (main chat, inline edit, compare mode) each need the
attribute, and the IME / i18n Playwright smoke must keep its env contract
minimal (no dead `STUDIO_OLD_PW`).
"""
from __future__ import annotations
import re
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
THREAD_TSX = REPO / "studio/frontend/src/components/assistant-ui/thread.tsx"
SHARED_TSX = REPO / "studio/frontend/src/features/chat/shared-composer.tsx"
WORKFLOW_YML = REPO / ".github/workflows/studio-ui-smoke.yml"
IME_PY = REPO / "tests/studio/playwright_chat_ime_i18n.py"
def _block_around(src: str, anchor: str, radius: int = 600) -> str:
idx = src.find(anchor)
assert idx != -1, f"anchor {anchor!r} not found"
return src[max(idx - radius, 0) : idx + radius]
def test_main_composer_has_dir_auto():
block = _block_around(THREAD_TSX.read_text(), 'aria-label="Message input"')
assert 'dir="auto"' in block, 'main composer is missing dir="auto"'
def test_edit_composer_has_dir_auto():
block = _block_around(THREAD_TSX.read_text(), "aui-edit-composer-input")
assert 'dir="auto"' in block, 'edit composer is missing dir="auto"'
def test_compare_composer_has_dir_auto():
block = _block_around(SHARED_TSX.read_text(), "Send to both models")
assert 'dir="auto"' in block, 'compare composer is missing dir="auto"'
def test_ime_workflow_step_does_not_set_studio_old_pw():
yml = WORKFLOW_YML.read_text()
drive_idx = yml.find("Drive IME + multilingual paste regression")
assert drive_idx != -1, "IME drive step not found in workflow"
next_step_idx = yml.find("- name:", drive_idx + 1)
drive_block = yml[drive_idx : next_step_idx if next_step_idx != -1 else None]
assert (
"STUDIO_OLD_PW" not in drive_block
), "IME drive step still passes dead STUDIO_OLD_PW env var"
assert "STUDIO_NEW_PW" in drive_block, "IME drive step missing STUDIO_NEW_PW"
def test_ime_pass_password_step_does_not_export_old_pw():
yml = WORKFLOW_YML.read_text()
pass_idx = yml.find("Pass bootstrap pw for IME / i18n test")
assert pass_idx != -1, "IME password setup step not found"
next_step_idx = yml.find("- name:", pass_idx + 1)
pass_block = yml[pass_idx : next_step_idx if next_step_idx != -1 else None]
assert (
"STUDIO_IME_OLD_PW" not in pass_block
), "IME password setup still exports dead STUDIO_IME_OLD_PW"
assert "STUDIO_IME_NEW_PW" in pass_block
def test_ime_playwright_script_does_not_read_studio_old_pw():
src = IME_PY.read_text()
code_only = re.sub(r'""".*?"""', "", src, flags = re.DOTALL)
assert (
"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

View file

@ -0,0 +1,128 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team.
"""Pinned-symbol canary for unsloth-zoo save_pretrained_merged guards
(unslothai/unsloth-zoo#647 / unslothai/unsloth#5410). Skips until #647
lands, then becomes a hard gate. CPU-only static fetch."""
from __future__ import annotations
import re
import pytest
from tests.version_compat._fetch import fetch_text
ZOO_TAG = "main"
def _fetch_saving_utils() -> str:
src = fetch_text("unslothai/unsloth-zoo", ZOO_TAG, "unsloth_zoo/saving_utils.py")
if src is None:
pytest.skip("unsloth_zoo/saving_utils.py not fetchable")
return src
def _fetch_merge_tests() -> str:
src = fetch_text(
"unslothai/unsloth-zoo",
ZOO_TAG,
"tests/test_unsloth_zoo_lora_merge.py",
)
if src is None:
pytest.skip("tests/test_unsloth_zoo_lora_merge.py not fetchable")
return src
def _skip_until_pr_647_lands(src: str) -> None:
if not any(
m in src
for m in (
"_MOE_MERGE_STATE",
"_detect_moe_lora_layout",
"_resolve_num_experts_from_lora_stats",
)
):
pytest.skip(
"unslothai/unsloth-zoo#647 has not yet merged into main; "
"tests auto-promote to hard gates once it lands."
)
def test_zoo_saving_utils_has_moe_merge_state():
src = _fetch_saving_utils()
_skip_until_pr_647_lands(src)
for sym in (
"_MOE_MERGE_STATE",
"_reset_moe_merge_state",
"_record_moe_merge_fallback",
):
assert sym in src, f"{sym} missing from saving_utils.py (issue #5410 guard)."
# zoo#647 wraps the fallback guard's message onto a second line;
# allow the regex to span newlines via re.DOTALL.
assert re.search(
r"raise\s+RuntimeError\b.*?MoE", src, re.IGNORECASE | re.DOTALL
), "no `raise RuntimeError(...MoE...)`; post-loop guard weakened."
def test_zoo_saving_utils_has_layout_detector():
src = _fetch_saving_utils()
_skip_until_pr_647_lands(src)
assert (
"_detect_moe_lora_layout" in src
), "_detect_moe_lora_layout removed (issue #5410)."
assert (
'"swapped"' in src and '"standard"' in src
), "one of the layout labels removed."
def test_zoo_saving_utils_has_num_experts_resolver():
src = _fetch_saving_utils()
_skip_until_pr_647_lands(src)
assert "_resolve_num_experts_from_lora_stats" in src, "resolver removed (#5410)."
assert re.search(
r"for\s+_\s+in\s+range\s*\(\s*\d+\s*\)", src
), "resolver walk no longer bounded by `for _ in range(N):`."
def test_zoo_saving_utils_writes_generation_config():
src = _fetch_saving_utils()
_skip_until_pr_647_lands(src)
# zoo#647 binds the generation_config attr to a local var
# (`gen_cfg = getattr(model, "generation_config", ...); ...
# gen_cfg.save_pretrained(save_directory)`) so an exact
# `generation_config.save_pretrained(` substring no longer
# matches. Anchor on the conceptual operation: a `generation_config`
# mention plus a `.save_pretrained(` call nearby, which is what
# the canary actually cares about.
assert re.search(
r"generation_config[\s\S]{0,400}?\.save_pretrained\s*\(", src
), "generation_config.json no longer saved (#5410)."
def test_zoo_lora_merge_tests_have_standard_layout_coverage():
src = _fetch_merge_tests()
if "test_merge_moe_gate_expert_standard_layout" not in src:
pytest.skip("unslothai/unsloth-zoo#647 not yet merged; coverage appears later.")
for name in (
"test_merge_moe_gate_expert_standard_layout",
"test_merge_moe_up_expert_standard_layout",
"test_merge_moe_down_proj_expert_standard_layout",
"test_detect_moe_lora_layout_classifies_both_conventions",
"test_moe_merge_fallback_counter_records_bad_layout",
"test_resolve_num_experts_walks_base_layer_chain",
):
assert name in src, f"regression test `{name}` removed."
def test_unsloth_save_pretrained_merged_entry_point_exists():
import pathlib
save_py = pathlib.Path(__file__).resolve().parents[2] / "unsloth" / "save.py"
if not save_py.is_file():
pytest.skip(f"{save_py} not present")
text = save_py.read_text(encoding = "utf-8", errors = "replace")
assert "save_pretrained_merged" in text, "entry point removed from unsloth/save.py."
assert (
"merge_and_overwrite_lora" in text
), "no dispatch into unsloth_zoo merge; #647 bypassed."

View file

@ -527,6 +527,45 @@ This sentence-transformers model was finetuned and converted to GGUF format usin
class FastSentenceTransformer(FastModel):
@staticmethod
def _save_base_config_for_processor_resume(config, output_path):
"""
sentence-transformers >= 5.4 reloads Transformer modules through
AutoProcessor. Tokenizer-only checkpoint roots make AutoProcessor fall
back to AutoConfig, so PEFT adapter checkpoints still need the base
config.json next to adapter_config.json.
"""
if config is None or not getattr(config, "model_type", None):
return
if hasattr(config, "save_pretrained"):
config.save_pretrained(output_path)
elif hasattr(config, "to_json_file"):
config_path = os.path.join(output_path, "config.json")
config.to_json_file(config_path)
@staticmethod
def _patch_transformer_module_save_config(transformer_module, base_config = None):
transformer_module._unsloth_st_managed = True
if base_config is not None and getattr(base_config, "model_type", None):
transformer_module._unsloth_base_config = base_config
if getattr(transformer_module, "_unsloth_save_config_patched", False):
return transformer_module
original_save = transformer_module.save
def _save_with_base_config(self, output_path, *args, **kwargs):
original_save(output_path, *args, **kwargs)
FastSentenceTransformer._save_base_config_for_processor_resume(
getattr(self, "_unsloth_base_config", None), output_path
)
transformer_module.save = types.MethodType(
_save_with_base_config, transformer_module
)
transformer_module._unsloth_save_config_patched = True
return transformer_module
@staticmethod
def _read_pooling_mode(model_name, token):
"""
@ -1157,6 +1196,9 @@ class FastSentenceTransformer(FastModel):
config_keys.append(config_key)
transformer_module.config_keys = config_keys
transformer_module.save_in_root = True
FastSentenceTransformer._patch_transformer_module_save_config(
transformer_module, getattr(model, "config", None)
)
if hasattr(model, "config"):
model.config.tokenizer_class = tokenizer.__class__.__name__
@ -1644,6 +1686,9 @@ class FastSentenceTransformer(FastModel):
st_model._dtype = dtype
st_model._load_in_4bit = load_in_4bit
st_model.no_modules = False
FastSentenceTransformer._patch_transformer_module_save_config(
st_model[0], getattr(st_model[0].auto_model, "config", None)
)
# Add save methods
def _save_pretrained_merged(self, save_directory, **save_kwargs):
@ -2067,6 +2112,9 @@ class FastSentenceTransformer(FastModel):
transformer_module.model = peft_model
else:
transformer_module.auto_model = peft_model
FastSentenceTransformer._patch_transformer_module_save_config(
transformer_module, getattr(inner_model, "config", None)
)
# Store compile info for auto-compile at trainer time
# torch.compile is deferred until training starts so we can check max_steps
@ -2121,6 +2169,9 @@ class FastSentenceTransformer(FastModel):
transformer_module.model = peft_model
else:
transformer_module.auto_model = peft_model
FastSentenceTransformer._patch_transformer_module_save_config(
transformer_module, getattr(inner_model, "config", None)
)
return model
else:
return FastModel.get_peft_model(
@ -2235,5 +2286,133 @@ def _patch_sentence_transformer_trainer():
SentenceTransformerTrainer._unsloth_auto_compile_patched = True
# Auto-patch trainer on module import
def _patch_st_trainer_load_from_checkpoint():
try:
from sentence_transformers import SentenceTransformerTrainer
except ImportError:
return
if getattr(
SentenceTransformerTrainer, "_unsloth_load_from_checkpoint_patched", False
):
return
if not hasattr(SentenceTransformerTrainer, "_load_from_checkpoint"):
return
_original = SentenceTransformerTrainer._load_from_checkpoint
def _unsloth_load_from_checkpoint(self, checkpoint_path):
try:
from peft import PeftModel, load_peft_weights, set_peft_model_state_dict
except ImportError:
return _original(self, checkpoint_path)
try:
mod0 = self.model[0]
except (IndexError, TypeError):
return _original(self, checkpoint_path)
if isinstance(getattr(type(mod0), "auto_model", None), property):
inner = getattr(mod0, "model", None)
else:
inner = getattr(mod0, "auto_model", None)
inner = getattr(inner, "_orig_mod", inner)
if not isinstance(inner, PeftModel):
return _original(self, checkpoint_path)
if not getattr(mod0, "_unsloth_st_managed", False):
return _original(self, checkpoint_path)
if not any(
os.path.isfile(os.path.join(checkpoint_path, fn))
for fn in ("adapter_model.safetensors", "adapter_model.bin")
):
return _original(self, checkpoint_path)
adapter_name = getattr(inner, "active_adapter", None)
if adapter_name is None and callable(getattr(inner, "active_adapters", None)):
adapter_name = inner.active_adapters()
if isinstance(adapter_name, (list, tuple, set)):
if len(adapter_name) != 1:
raise RuntimeError(
"Unsloth: Cannot resume multiple active PEFT adapters."
)
adapter_name = next(iter(adapter_name))
adapter_name = adapter_name or "default"
if adapter_name not in getattr(inner, "peft_config", {}):
raise RuntimeError(f"Unsloth: PEFT adapter {adapter_name!r} is not loaded.")
load_result = set_peft_model_state_dict(
inner, load_peft_weights(checkpoint_path), adapter_name = adapter_name
)
unexpected = getattr(load_result, "unexpected_keys", []) or []
missing = [
x
for x in (getattr(load_result, "missing_keys", []) or [])
if f".{adapter_name}." in x or x.endswith(f".{adapter_name}")
]
if unexpected or missing:
raise RuntimeError(
"Unsloth: PEFT checkpoint does not match the active adapter "
f"(missing={missing[:8]}, unexpected={unexpected[:8]})."
)
modules_json = os.path.join(checkpoint_path, "modules.json")
if not os.path.isfile(modules_json):
raise RuntimeError("Unsloth: PEFT checkpoint is missing modules.json.")
try:
with open(modules_json, "r") as f:
module_configs = json.load(f)
except Exception as e:
raise RuntimeError("Unsloth: Cannot parse checkpoint modules.json.") from e
root = os.path.abspath(os.fspath(checkpoint_path))
restored = set()
for entry in module_configs:
idx = int(entry.get("idx", -1))
if idx == 0:
continue
if idx < 0 or idx >= len(self.model):
raise RuntimeError(f"Unsloth: Bad module index in modules.json: {idx}.")
module = self.model[idx]
module_cls = type(module)
saved_type = entry.get("type", "")
if saved_type and not saved_type.endswith(f".{module_cls.__name__}"):
raise RuntimeError(f"Unsloth: Checkpoint module {idx} type mismatch.")
module_path = entry.get("path")
module_dir = os.path.abspath(
os.path.join(root, os.fspath(module_path or ""))
)
try:
inside_root = os.path.commonpath([root, module_dir]) == root
except ValueError:
inside_root = False
if not module_path or not inside_root or not os.path.isdir(module_dir):
raise RuntimeError(
f"Unsloth: Bad checkpoint module path for index {idx}."
)
if not hasattr(module_cls, "load"):
raise RuntimeError(f"Unsloth: Module {idx} cannot be reloaded.")
fresh = module_cls.load(module_dir)
if not isinstance(fresh, module_cls):
raise RuntimeError(f"Unsloth: Module {idx} reload returned wrong type.")
# Parameterless modules (Pooling, Normalize) make
# next(module.parameters()) raise StopIteration; route through
# the SentenceTransformer's device property instead.
try:
fresh.to(self.model.device)
except AttributeError:
pass
self.model[idx] = fresh
restored.add(idx)
missing_idx = sorted(set(range(1, len(self.model))) - restored)
if missing_idx:
raise RuntimeError(
f"Unsloth: Checkpoint modules.json is incomplete (missing idx={missing_idx[:8]})."
)
SentenceTransformerTrainer._load_from_checkpoint = _unsloth_load_from_checkpoint
SentenceTransformerTrainer._unsloth_load_from_checkpoint_patched = True
_patch_sentence_transformer_trainer()
_patch_st_trainer_load_from_checkpoint()