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:
commit
00858b1178
16 changed files with 2571 additions and 597 deletions
55
.github/workflows/studio-ui-smoke.yml
vendored
55
.github/workflows/studio-ui-smoke.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
.github/workflows/version-compat-ci.yml
vendored
1
.github/workflows/version-compat-ci.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
237
studio/backend/tests/test_gguf_reload_inheritance.py
Normal file
237
studio/backend/tests/test_gguf_reload_inheritance.py
Normal 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
|
||||
|
|
@ -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 == []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
457
tests/studio/playwright_chat_ime_i18n.py
Normal file
457
tests/studio/playwright_chat_ime_i18n.py
Normal 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()
|
||||
73
tests/studio/test_composer_rtl_bidi_attribute.py
Normal file
73
tests/studio/test_composer_rtl_bidi_attribute.py
Normal 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
|
||||
|
|
@ -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."
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue