Merge branch 'main' into feature/chat-api

This commit is contained in:
Lee Jackson 2026-05-07 10:47:18 +01:00 committed by GitHub
commit f345dc1908
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 470 additions and 16 deletions

View file

@ -316,15 +316,28 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({
if (message.status?.type !== "running") {
return false;
}
const lastIndex = message.parts.length - 1;
if (lastIndex < 0) {
const parts = message.parts;
const len = parts.length;
if (len === 0) {
return false;
}
const lastType = message.parts[lastIndex]?.type;
if (lastType !== "reasoning") {
let groupHasReasoning = false;
for (let i = startIndex; i <= endIndex && i < len; i += 1) {
if (parts[i]?.type === "reasoning") {
groupHasReasoning = true;
break;
}
}
if (!groupHasReasoning) {
return false;
}
return lastIndex >= startIndex && lastIndex <= endIndex;
for (let i = endIndex + 1; i < len; i += 1) {
if (parts[i]?.type !== "tool-call") {
return false;
}
}
return true;
});
const persistedDuration = useAuiState(({ message }) => {

View file

@ -96,11 +96,11 @@ export function UsageExamples() {
};
return (
<section className="flex flex-col">
<section className="flex min-w-0 max-w-full flex-col">
<h2 className="mb-2 text-sm font-semibold text-foreground">Usage examples</h2>
<div className="overflow-hidden rounded-lg border border-border bg-muted/20">
<div className="flex items-center justify-between border-b border-border px-2 py-1.5">
<div className="flex items-center gap-0.5">
<div className="min-w-0 max-w-full overflow-hidden rounded-lg border border-border bg-muted/20">
<div className="flex min-w-0 items-center justify-between gap-2 border-b border-border px-2 py-1.5">
<div className="flex min-w-0 items-center gap-0.5">
{TABS.map((t) => {
const active = lang === t.id;
return (
@ -134,7 +134,7 @@ export function UsageExamples() {
{copied ? "Copied" : "Copy"}
</button>
</div>
<pre className="overflow-x-auto p-3 font-mono text-[11px] leading-relaxed text-foreground">
<pre className="max-w-full overflow-x-auto whitespace-pre-wrap break-words p-3 font-mono text-[11px] leading-relaxed text-foreground">
{snippets[lang]}
</pre>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">

View file

@ -163,7 +163,7 @@ export function SettingsDialog() {
>
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
</button>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-6">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-6">
{renderTab(activeTab)}
</div>
</main>

View file

@ -61,8 +61,8 @@ export function ApiKeysTab() {
};
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<div className="flex min-w-0 max-w-full flex-col gap-6">
<header className="flex min-w-0 flex-col gap-1">
<h1 className="text-lg font-semibold font-heading">API</h1>
<p className="text-xs text-muted-foreground">
Access Unsloth programmatically via the OpenAI-compatible API.{" "}
@ -111,7 +111,7 @@ export function ApiKeysTab() {
)}
</AnimatePresence>
<section className="flex flex-col">
<section className="flex min-w-0 flex-col">
<h2 className="mb-2 text-sm font-semibold text-foreground">Access tokens</h2>
{error ? (
<div className="rounded-md border border-destructive/20 bg-destructive/5 p-3 text-xs text-destructive">
@ -131,7 +131,7 @@ export function ApiKeysTab() {
No API access yet.
</p>
) : (
<div className="flex flex-col">
<div className="flex min-w-0 flex-col">
{keys.map((k) => (
<ApiKeyRow key={k.id} apiKey={k} onRevoke={setRevokeTarget} />
))}

View file

@ -0,0 +1,421 @@
#!/usr/bin/env python3
"""Smoke test: N parallel install.sh runs with distinct UNSLOTH_STUDIO_HOME
values must produce N fully isolated installs whose backends can run
side by side without clashing.
Covers the env-override path added in #5190:
install-time
* N concurrent ``install.sh --local --no-torch`` runs against
this checkout, each pinned to its own UNSLOTH_STUDIO_HOME and
a redirected HOME, all exit 0.
* Each STUDIO_HOME contains its own bin/, share/, llama.cpp/
and unsloth_studio/ venv, with no cross-install absolute
paths.
* share/studio_install_id is unique across the N installs.
* share/studio.conf exports UNSLOTH_EXE, UNSLOTH_STUDIO_HOME
and UNSLOTH_LLAMA_CPP_PATH, all pointing inside this install.
* share/launch-studio.sh has @@DATA_DIR@@ substituted to its
own share/ at install time.
* bin/unsloth is a symlink that resolves into its own venv.
* The redirected HOME is left clean: no shell-rc append, no
.desktop file, no Studio.app stub, no shared marker.
runtime
* N concurrent ``bin/unsloth studio`` launches each bind their
own dynamically allocated free port and stay healthy.
* /api/health is 200, status is healthy, chat_only is true
under --no-torch.
* The studio_root_id reported by /api/health on each backend
equals that install's share/studio_install_id, so the
runtime resolver agrees with the install-time write.
* studio_root_id values are pairwise distinct.
* GET / and GET /api/chat are 200 on every backend.
* The Python interpreter behind each PID is the install's own
venv python (the bin/unsloth shim does not cross-resolve).
This is an integration smoke runner, not a pytest unit test. It does
real installs (~1 minute end to end on a warm uv cache) and is meant
to be invoked explicitly:
python tests/studio/install/smoke_test_parallel_studio_home.py
python tests/studio/install/smoke_test_parallel_studio_home.py --n 6 --keep
Exits 0 on PASS, 1 on FAIL, 2 on infrastructure error. Artifacts land
under a temporary directory and are removed on PASS unless --keep is
set; on FAIL or ERROR they are kept regardless so logs can be
inspected.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
INSTALL_TIMEOUT_S = 600
HEALTH_TIMEOUT_S = 120
HEALTH_POLL_INTERVAL_S = 1.0
class TestFailure(AssertionError):
pass
def _log(msg: str) -> None:
ts = datetime.now().strftime("%H:%M:%S")
print(f"[smoke {ts}] {msg}", flush = True)
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _run_one_install(
label: str,
repo: Path,
studio_home: Path,
fake_home: Path,
uv_cache: Path,
log_path: Path,
) -> tuple[str, int]:
studio_home.mkdir(parents = True, exist_ok = True)
fake_home.mkdir(parents = True, exist_ok = True)
uv_cache.mkdir(parents = True, exist_ok = True)
log_path.parent.mkdir(parents = True, exist_ok = True)
env = os.environ.copy()
env["HOME"] = str(fake_home)
env["UNSLOTH_STUDIO_HOME"] = str(studio_home)
env["UV_CACHE_DIR"] = str(uv_cache)
env["NO_COLOR"] = "1"
with log_path.open("w") as fh:
proc = subprocess.run(
["bash", "install.sh", "--local", "--no-torch"],
cwd = str(repo),
env = env,
stdout = fh,
stderr = subprocess.STDOUT,
timeout = INSTALL_TIMEOUT_S,
)
return label, proc.returncode
def _launch_backend(
studio_home: Path, fake_home: Path, port: int, log_path: Path
) -> subprocess.Popen:
log_path.parent.mkdir(parents = True, exist_ok = True)
env = os.environ.copy()
env["HOME"] = str(fake_home)
# Pin UNSLOTH_STUDIO_HOME (and clear the alias) so the child cannot
# inherit a Studio root from the caller's shell. Without this, a shell
# that already exports either var would override the per-label sys.prefix
# inference and every backend would resolve to the caller's install.
env["UNSLOTH_STUDIO_HOME"] = str(studio_home)
env.pop("STUDIO_HOME", None)
# The child process inherits a dup of stdout via Popen, so closing the
# parent's handle when this function returns is safe and avoids relying
# on GC timing to release the fd.
with log_path.open("w") as fh:
return subprocess.Popen(
[
str(studio_home / "bin" / "unsloth"),
"studio",
"-H",
"127.0.0.1",
"-p",
str(port),
"--silent",
],
env = env,
stdout = fh,
stderr = subprocess.STDOUT,
start_new_session = True,
)
def _wait_for_health(port: int, timeout: float) -> dict:
deadline = time.time() + timeout
last_err: Exception | None = None
url = f"http://127.0.0.1:{port}/api/health"
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout = 2) as r:
if r.status == 200:
return json.loads(r.read().decode())
except (urllib.error.URLError, ConnectionError, OSError) as e:
last_err = e
time.sleep(HEALTH_POLL_INTERVAL_S)
raise TestFailure(
f"port {port}: /api/health never returned 200 (last_err={last_err})"
)
def _http_status(port: int, path: str, timeout: float = 5.0) -> int:
url = f"http://127.0.0.1:{port}{path}"
try:
with urllib.request.urlopen(url, timeout = timeout) as r:
return r.status
except urllib.error.HTTPError as e:
return e.code
def _check_install_layout(label: str, studio_home: Path) -> dict:
for d in ("bin", "share", "llama.cpp", "unsloth_studio"):
if not (studio_home / d).is_dir():
raise TestFailure(f"[{label}] missing {studio_home / d}")
shim = studio_home / "bin" / "unsloth"
if not shim.is_symlink():
raise TestFailure(f"[{label}] {shim} is not a symlink")
expected_target = (studio_home / "unsloth_studio" / "bin" / "unsloth").resolve()
if shim.resolve() != expected_target:
raise TestFailure(
f"[{label}] shim resolves to {shim.resolve()}, expected {expected_target}"
)
install_id_path = studio_home / "share" / "studio_install_id"
if not install_id_path.is_file():
raise TestFailure(f"[{label}] missing {install_id_path}")
install_id = install_id_path.read_text().strip()
if len(install_id) < 32:
raise TestFailure(f"[{label}] studio_install_id too short: {install_id!r}")
conf = (studio_home / "share" / "studio.conf").read_text()
must_contain = [
f"UNSLOTH_EXE='{studio_home}/unsloth_studio/bin/unsloth'",
f"export UNSLOTH_STUDIO_HOME='{studio_home}'",
f"export UNSLOTH_LLAMA_CPP_PATH='{studio_home}/llama.cpp'",
]
for needle in must_contain:
if needle not in conf:
raise TestFailure(
f"[{label}] studio.conf missing line:\n {needle}\n" f"actual:\n{conf}"
)
launcher = (studio_home / "share" / "launch-studio.sh").read_text()
if "@@DATA_DIR@@" in launcher:
raise TestFailure(f"[{label}] launch-studio.sh kept @@DATA_DIR@@ placeholder")
expected_data_dir_line = f"DATA_DIR='{studio_home}/share'"
if expected_data_dir_line not in launcher:
raise TestFailure(
f"[{label}] launch-studio.sh missing {expected_data_dir_line!r}"
)
return {"label": label, "studio_home": str(studio_home), "install_id": install_id}
def _check_fake_home_clean(fake_home: Path) -> None:
forbidden = [
".bashrc",
".zshrc",
".profile",
".unsloth",
Path(".local") / "share" / "applications" / "unsloth-studio.desktop",
Path("Desktop") / "unsloth-studio.desktop",
Path("Applications") / "Unsloth Studio.app",
]
leaked = [str(p) for p in forbidden if (fake_home / p).exists()]
if leaked:
raise TestFailure(
f"redirected HOME picked up persistent install pollution: {leaked}"
)
def _backend_pid_python(pid: int) -> Path | None:
"""Resolve the binary backing a running PID. Linux exposes this at
/proc/PID/exe; on platforms without /proc (macOS, BSD, Windows) we
skip this check and rely on the install-time symlink + studio.conf
invariants to catch cross-resolution. Returns None when /proc is
unavailable so the caller can skip cleanly."""
if sys.platform != "linux":
return None
proc_exe = Path(f"/proc/{pid}/exe")
if not proc_exe.exists():
return None
return proc_exe.resolve()
def run(n_installs: int, keep: bool) -> int:
if n_installs < 2:
raise TestFailure("--n must be >= 2 to test for clashes")
labels = [chr(ord("a") + i) for i in range(n_installs)]
repo = PACKAGE_ROOT
if not (repo / "install.sh").is_file():
raise TestFailure(
f"install.sh not found at {repo}; " "run from a clone of unslothai/unsloth"
)
test_root = Path(tempfile.mkdtemp(prefix = "unsloth_studio_clash_"))
_log(f"test root: {test_root}")
_log(f"repo: {repo}")
backends: list[tuple[str, Path, Path, int, subprocess.Popen]] = []
failed = False
try:
# ---- parallel installs --------------------------------------------
_log(f"launching {n_installs} parallel installs (--local --no-torch)")
with ThreadPoolExecutor(max_workers = n_installs) as pool:
futures = []
for label in labels:
futures.append(
pool.submit(
_run_one_install,
label,
repo,
test_root / "installs" / label,
test_root / "fake_homes" / label,
test_root / "uv_caches" / label,
test_root / "logs" / f"install_{label}.log",
)
)
for fut in as_completed(futures):
label, rc = fut.result()
_log(f" install {label}: exit {rc}")
if rc != 0:
raise TestFailure(
f"install {label} failed (rc={rc}); see "
f"{test_root / 'logs' / f'install_{label}.log'}"
)
# ---- install-layout invariants ------------------------------------
_log("verifying install-time invariants")
observed = []
for label in labels:
studio_home = test_root / "installs" / label
obs = _check_install_layout(label, studio_home)
observed.append(obs)
_check_fake_home_clean(test_root / "fake_homes" / label)
ids = [o["install_id"] for o in observed]
if len(set(ids)) != len(ids):
raise TestFailure(f"studio_install_id collision: {ids}")
_log(f" {len(ids)} unique studio_install_ids, all redirected HOMEs clean")
# ---- parallel backend launches ------------------------------------
_log(f"launching {n_installs} backends in parallel")
for label in labels:
port = _free_port()
studio_home = test_root / "installs" / label
fake_home = test_root / "fake_homes" / label
log_path = test_root / "logs" / f"run_{label}.log"
proc = _launch_backend(studio_home, fake_home, port, log_path)
backends.append((label, studio_home, fake_home, port, proc))
_log(f" {label} -> port {port} (pid {proc.pid})")
# ---- wait for health ----------------------------------------------
_log("waiting for /api/health on each backend")
health_payloads: dict[str, dict] = {}
with ThreadPoolExecutor(max_workers = n_installs) as pool:
fut_to_label = {
pool.submit(_wait_for_health, port, HEALTH_TIMEOUT_S): label
for (label, _sh, _fh, port, _p) in backends
}
for fut in as_completed(fut_to_label):
label = fut_to_label[fut]
health_payloads[label] = fut.result()
_log(f" {label}: healthy")
# ---- runtime invariants -------------------------------------------
_log("checking runtime invariants")
seen_root_ids: set[str] = set()
for (label, studio_home, _fh, port, proc), obs in zip(backends, observed):
health = health_payloads[label]
if health.get("status") != "healthy":
raise TestFailure(f"[{label}] health status != healthy: {health}")
if health.get("studio_root_id") != obs["install_id"]:
raise TestFailure(
f"[{label}] runtime studio_root_id "
f"{health.get('studio_root_id')!r} != install_id "
f"{obs['install_id']!r}"
)
if not health.get("chat_only"):
raise TestFailure(f"[{label}] chat_only is not true under --no-torch")
if health["studio_root_id"] in seen_root_ids:
raise TestFailure(
f"[{label}] studio_root_id collision at runtime: "
f"{health['studio_root_id']}"
)
seen_root_ids.add(health["studio_root_id"])
for path in ("/", "/api/chat"):
code = _http_status(port, path)
if code != 200:
raise TestFailure(f"[{label}] GET {path} -> {code}")
exe = _backend_pid_python(proc.pid)
if exe is not None:
expected_python = (
studio_home / "unsloth_studio" / "bin" / "python"
).resolve()
if exe != expected_python:
raise TestFailure(
f"[{label}] PID {proc.pid} exe={exe}, expected {expected_python}"
)
versions = {h.get("version") for h in health_payloads.values()}
if len(versions) != 1:
raise TestFailure(f"version mismatch across installs: {versions}")
_log(
f"PASS: all install + runtime invariants hold "
f"(version={next(iter(versions))})"
)
return 0
except TestFailure as e:
_log(f"FAIL: {e}")
failed = True
return 1
except Exception as e:
_log(f"ERROR: {type(e).__name__}: {e}")
failed = True
return 2
finally:
for _lbl, _sh, _fh, _port, proc in backends:
if proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout = 10)
except Exception:
proc.kill()
if keep or failed:
_log(f"artifacts kept at {test_root}")
else:
shutil.rmtree(test_root, ignore_errors = True)
_log(f"cleaned up {test_root}")
def main() -> int:
ap = argparse.ArgumentParser(description = __doc__)
ap.add_argument(
"--n",
type = int,
default = 4,
help = "number of parallel installs (default 4, must be >= 2)",
)
ap.add_argument(
"--keep",
action = "store_true",
help = "leave the temp test root on disk even on PASS",
)
args = ap.parse_args()
return run(args.n, args.keep)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -2483,6 +2483,7 @@ def patch_tokenizer(model, tokenizer):
def patch_fast_lora():
import peft.tuners.lora.bnb
from ..kernels.fast_lora import fast_lora_forward
peft.tuners.lora.bnb.Linear4bit.forward = fast_lora_forward

View file

@ -1780,7 +1780,20 @@ def openenv_vllm_reload_weights():
patch_target_name = "generate_rollout_completions"
patch_target = getattr(openenv_utils, patch_target_name)
src = inspect.getsource(patch_target)
# TRL 0.29.1+ ships some openenv helpers as compiled bytecode without
# accessible source on disk; inspect.getsource raises OSError("could
# not get source code") in that case. Skip the source-rewrite patch
# rather than crashing -- the core unsloth weight-reload path stays
# functional, only the wake_up tag rewrite is skipped.
try:
src = inspect.getsource(patch_target)
except OSError as e:
logger.warning(
f"Unsloth: Could not retrieve source for trl openenv "
f"{patch_target_name} ({e}); skipping rewrite. "
f"Weight reload still functional."
)
return
src = textwrap.dedent(src)
original_src = src

View file

@ -1580,6 +1580,12 @@ def patch_sft_trainer_tokenizer():
except:
return
all_imports = dir(trl.trainer.sft_trainer)
# Make typing names available to the exec'd source bodies. TRL >= 1.x
# type-hints _prepare_dataset / _prepare_non_packed_dataloader with
# `Union[...]` and friends; without these imports in the exec namespace
# those become NameErrors at exec time. Mirrors the pattern used in
# unsloth/models/_utils.py:patch_linear_scaling.
from typing import Union, Optional, List, Any, Callable, Tuple, Dict, Iterator # noqa: F401
for (
function_name,