Merge branch 'main' into dh/fix-5106-windows-cudart-pair
21
.github/dependabot.yml
vendored
|
|
@ -24,4 +24,25 @@ updates:
|
|||
groups:
|
||||
npm-oxc-validator:
|
||||
patterns: ["*"]
|
||||
|
||||
# pip + cargo so security advisories on Python deps + the Tauri shell
|
||||
# auto-generate PRs alongside the github-actions / bun / npm updates.
|
||||
# Grouped weekly so we don't get one PR per dep; security advisories
|
||||
# bypass the group and open immediately.
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
python:
|
||||
patterns: ["*"]
|
||||
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/studio/src-tauri"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
cargo-tauri:
|
||||
patterns: ["*"]
|
||||
...
|
||||
|
|
|
|||
2144
.github/workflows/consolidated-tests-ci.yml
vendored
Normal file
319
.github/workflows/lint-ci.yml
vendored
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Whole-repo, multi-language source-lint gate. Runs on every PR
|
||||
# (no path filter) because each step is sub-second to a few seconds
|
||||
# and together they catch a class of breakage the focused build
|
||||
# workflows would miss:
|
||||
#
|
||||
# - Python syntax + ruff + leftover debugger calls (across 350+
|
||||
# committed .py files, not just studio/backend).
|
||||
# - Shell `bash -n` parse for every committed *.sh.
|
||||
# - `yaml.safe_load` and `json.loads` round-trip for every
|
||||
# committed YAML / JSON config.
|
||||
#
|
||||
# TypeScript and Rust are NOT duplicated here on purpose:
|
||||
# - Studio Frontend CI runs `npm run typecheck` (= `tsc --noEmit`)
|
||||
# and `npm run build` (vite/swc) on every studio/frontend/**
|
||||
# change, which is a full TS AST + type check.
|
||||
# - Studio Tauri CI runs `tauri build --debug --no-bundle` on
|
||||
# every studio/src-tauri/** or studio/frontend/** change, which
|
||||
# compiles the Rust crate (= cargo check + cargo build).
|
||||
# Each is a stricter check than a parse-only step would be, so a
|
||||
# fast-fail duplicate here would only burn cache; the dedicated
|
||||
# workflows already block merges on Rust / TS regressions.
|
||||
|
||||
name: Lint CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main, pip]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
source-lint:
|
||||
name: Source lint (Python + shell + YAML + JSON + safety nets)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
# Pin ruff to match .pre-commit-config.yaml so a CI-only ruff
|
||||
# bump cannot disagree with what pre-commit accepted.
|
||||
# codespell is pinned for the same reason: a reviewer should
|
||||
# never see a typo report appear and disappear depending on
|
||||
# which codespell version the runner happened to install.
|
||||
- run: pip install 'ruff==0.15.12' 'pyyaml>=6' 'codespell>=2.3,<3'
|
||||
|
||||
- name: Linux deps for shellcheck
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends shellcheck
|
||||
|
||||
- name: Python AST/syntax check (every committed .py must compile)
|
||||
# python -m compileall uses the same parser the interpreter
|
||||
# uses, so anything broken here would also crash at
|
||||
# `import X` on a user's machine. Sub-second across 350+
|
||||
# files. Hard gate.
|
||||
run: |
|
||||
python -m compileall -q -j 0 \
|
||||
unsloth unsloth_cli studio tests cli.py unsloth-cli.py
|
||||
|
||||
- name: Python ruff check (whole repo)
|
||||
# The narrow rule set in pyproject.toml [tool.ruff.lint]
|
||||
# selects E9 / F63 / F7 / F82 -- syntax errors, broken
|
||||
# comparisons, undefined names. The whole repo passes today,
|
||||
# so this is a hard gate.
|
||||
run: |
|
||||
ruff check unsloth unsloth_cli studio tests cli.py unsloth-cli.py
|
||||
|
||||
- name: No leftover debugger / pdb / breakpoint calls
|
||||
# Catches the "I'll just stick a breakpoint() here" mistake
|
||||
# before it ships. AST-based so commented-out debugger
|
||||
# markers don't false-positive (a bare grep would; there
|
||||
# are three commented `# breakpoint()` markers in
|
||||
# unsloth/models/rl* today). Sub-second.
|
||||
run: |
|
||||
python <<'PY'
|
||||
import ast, pathlib, sys
|
||||
|
||||
SKIP_PARTS = {".venv", "venv", "build", "dist", ".git",
|
||||
"unsloth_compiled_cache", "node_modules",
|
||||
"unsloth.egg-info"}
|
||||
|
||||
bad = []
|
||||
scanned = 0
|
||||
for path in sorted(pathlib.Path(".").rglob("*.py")):
|
||||
if any(part in SKIP_PARTS for part in path.parts):
|
||||
continue
|
||||
scanned += 1
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
|
||||
except SyntaxError:
|
||||
continue # compileall step above already failed this
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
fn = node.func
|
||||
if isinstance(fn, ast.Name) and fn.id == "breakpoint":
|
||||
bad.append((path, node.lineno, "breakpoint()"))
|
||||
elif (isinstance(fn, ast.Attribute) and fn.attr == "set_trace"
|
||||
and isinstance(fn.value, ast.Name)
|
||||
and fn.value.id in {"pdb", "ipdb"}):
|
||||
bad.append((path, node.lineno, f"{fn.value.id}.set_trace()"))
|
||||
|
||||
if bad:
|
||||
for path, lineno, what in bad:
|
||||
print(f"::error file={path},line={lineno}::leftover {what} -- remove before merging")
|
||||
sys.exit(1)
|
||||
print(f"no leftover debugger calls (scanned {scanned} files)")
|
||||
PY
|
||||
|
||||
- name: License-header drift (informational; whole repo)
|
||||
# Three header families are accepted across the repo:
|
||||
# 1. SPDX one-liner: `# SPDX-License-Identifier: ...`
|
||||
# Used across studio/ (AGPL-3.0-only) and a few new
|
||||
# files elsewhere.
|
||||
# 2. Apache-2.0 long form, marker phrase
|
||||
# "Licensed under the Apache License". Used across
|
||||
# unsloth/ and unsloth_cli/.
|
||||
# 3. GNU long form, marker phrase "General Public License".
|
||||
# That single substring covers GPL, LGPL ("GNU Lesser
|
||||
# General Public License") and AGPL ("GNU Affero
|
||||
# General Public License") preambles, all three of
|
||||
# which appear in unsloth/kernels/* (LGPL/AGPL) without
|
||||
# the SPDX line.
|
||||
# Empty files (mainly empty __init__.py) are skipped.
|
||||
# Surfaced as a warning; cleaning up the actual misses is a
|
||||
# follow-up PR, not a CI fix.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python <<'PY'
|
||||
import pathlib
|
||||
|
||||
ACCEPTED = (
|
||||
"SPDX-License-Identifier", # any SPDX line
|
||||
"Licensed under the Apache License", # Apache-2.0 long form
|
||||
"General Public License", # GPL / LGPL / AGPL long form
|
||||
)
|
||||
SKIP_PARTS = {".venv", "venv", "build", "dist", ".git",
|
||||
"unsloth_compiled_cache", "node_modules",
|
||||
"unsloth.egg-info"}
|
||||
|
||||
studio_missing = []
|
||||
other_missing = []
|
||||
for path in sorted(pathlib.Path(".").rglob("*.py")):
|
||||
if any(part in SKIP_PARTS for part in path.parts):
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if not text.strip():
|
||||
continue # empty __init__.py etc.
|
||||
head = "\n".join(text.splitlines()[:25])
|
||||
if any(marker in head for marker in ACCEPTED):
|
||||
continue
|
||||
if "studio" in path.parts:
|
||||
studio_missing.append(path)
|
||||
else:
|
||||
other_missing.append(path)
|
||||
|
||||
total = len(studio_missing) + len(other_missing)
|
||||
if total == 0:
|
||||
print("every committed .py has a recognised license header")
|
||||
else:
|
||||
print(f"::warning::{total} Python files have no recognised license "
|
||||
f"header (SPDX / Apache-2.0 / GNU long form): "
|
||||
f"studio={len(studio_missing)}, other={len(other_missing)}")
|
||||
for path in (studio_missing + other_missing)[:30]:
|
||||
print(f" {path}")
|
||||
if total > 30:
|
||||
print(f" ... and {total - 30} more")
|
||||
PY
|
||||
|
||||
- name: Shell scripts parse cleanly (`bash -n`)
|
||||
# Same idea as Python's compileall: parse-only check that
|
||||
# every committed *.sh would not blow up at `bash script.sh`
|
||||
# invocation time on a release box. tests/sh/ is the largest
|
||||
# cluster (the install.sh shape tests).
|
||||
run: |
|
||||
shopt -s globstar
|
||||
fail=0
|
||||
for f in $(git ls-files '*.sh'); do
|
||||
if ! bash -n "$f"; then
|
||||
echo "::error file=$f::shell parse error"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
n=$(git ls-files '*.sh' | wc -l)
|
||||
echo "$n shell scripts parse cleanly"
|
||||
|
||||
- name: YAML files parse cleanly (yaml.safe_load)
|
||||
# Catches truncated workflow files, broken indents in
|
||||
# dependabot.yml / pre-commit configs, etc. Includes
|
||||
# .github/workflows/*.yml so a typo in the file we just
|
||||
# added shows up immediately.
|
||||
run: |
|
||||
python <<'PY'
|
||||
import pathlib, sys, yaml
|
||||
|
||||
SKIP_PARTS = {".venv", "venv", "build", "dist", ".git",
|
||||
"node_modules", "unsloth_compiled_cache",
|
||||
"unsloth.egg-info"}
|
||||
|
||||
bad = []
|
||||
scanned = 0
|
||||
for path in sorted(list(pathlib.Path(".").rglob("*.yml"))
|
||||
+ list(pathlib.Path(".").rglob("*.yaml"))):
|
||||
if any(part in SKIP_PARTS for part in path.parts):
|
||||
continue
|
||||
scanned += 1
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
list(yaml.safe_load_all(fh))
|
||||
except Exception as exc:
|
||||
bad.append((path, exc))
|
||||
|
||||
if bad:
|
||||
for path, exc in bad:
|
||||
print(f"::error file={path}::YAML parse failed: {exc}")
|
||||
sys.exit(1)
|
||||
print(f"{scanned} YAML files parse cleanly")
|
||||
PY
|
||||
|
||||
- name: JSON files parse cleanly (json.loads)
|
||||
# Catches malformed package.json, biome.json, etc. Skips:
|
||||
# - huge npm/bun lockfiles (machine-generated, slow to
|
||||
# parse, no value).
|
||||
# - tsconfig*.json: TypeScript convention is JSONC (JSON
|
||||
# with `/* ... */` comments), which standard json.loads
|
||||
# rejects. Strip-and-validate would need json5 or a
|
||||
# hand-rolled comment scrubber for marginal value, since
|
||||
# `tsc --noEmit` already validates these in Frontend CI.
|
||||
run: |
|
||||
python <<'PY'
|
||||
import fnmatch, json, pathlib, sys
|
||||
|
||||
SKIP_PARTS = {".venv", "venv", "build", "dist", ".git",
|
||||
"node_modules", "unsloth_compiled_cache",
|
||||
"unsloth.egg-info"}
|
||||
SKIP_NAMES = {"package-lock.json", "bun.lock"}
|
||||
SKIP_PATTERNS = ("tsconfig*.json",)
|
||||
|
||||
bad = []
|
||||
scanned = 0
|
||||
for path in sorted(pathlib.Path(".").rglob("*.json")):
|
||||
if any(part in SKIP_PARTS for part in path.parts):
|
||||
continue
|
||||
if path.name in SKIP_NAMES:
|
||||
continue
|
||||
if any(fnmatch.fnmatch(path.name, pat) for pat in SKIP_PATTERNS):
|
||||
continue
|
||||
scanned += 1
|
||||
try:
|
||||
json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
bad.append((path, exc))
|
||||
|
||||
if bad:
|
||||
for path, exc in bad:
|
||||
print(f"::error file={path}::JSON parse failed: {exc}")
|
||||
sys.exit(1)
|
||||
print(f"{scanned} JSON files parse cleanly")
|
||||
PY
|
||||
|
||||
- name: codespell typo check (informational)
|
||||
# Catches typos in code, comments, and docs across the repo.
|
||||
# Skips lockfiles, generated assets, binary artefacts, and
|
||||
# the LICENSE files (US/UK spelling drift in legal text is
|
||||
# not ours to second-guess). The ignore-words-list pulls
|
||||
# out short identifiers + valid technical terms that
|
||||
# codespell's default dictionary would otherwise flag
|
||||
# (e.g. `ans` as a math-quiz variable name in
|
||||
# tests/utils/aime_eval.py, `parm`/`parms` in PyTorch
|
||||
# nn.Module idioms). Non-blocking until the surfaced typos
|
||||
# are fixed; drop continue-on-error after the cleanup.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
codespell \
|
||||
--skip='*.lock,*.lockb,*.json,*.svg,*.png,*.jpg,*.jpeg,*.gif,*.ico,*.woff*,*.ttf,*.eot,*.zip,*.gz,*.gguf,*.safetensors,*.bin,node_modules,.git,build,dist,unsloth_compiled_cache,unsloth.egg-info,target,studio/frontend/dist,*.pyc,*-licenses.txt,LICENSE*' \
|
||||
--ignore-words-list='ans,bu,hel,fo,te,ot,hist,ned,sav,recurser,datas,nin,parm,parms,checkin,nd,fr,inout,donot,uint' \
|
||||
--quiet-level=2
|
||||
|
||||
- name: shellcheck on committed *.sh (informational)
|
||||
# Goes beyond `bash -n` (which only parses): catches subtle
|
||||
# shell bugs like unquoted variable expansions, useless
|
||||
# `cat`, command substitutions inside `[[`, etc. The
|
||||
# install/setup scripts are critical-path so the signal is
|
||||
# worth surfacing. Non-blocking until install.sh's
|
||||
# hand-rolled patterns get cleaned up; drop continue-on-error
|
||||
# afterwards.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Exclude SC1090 ("source not followable") -- legitimate
|
||||
# for installer scripts that source files at runtime
|
||||
# paths shellcheck cannot resolve statically.
|
||||
# SC2034 ("variable assigned but never used") fires on
|
||||
# the export-only assignment idiom we use in install.sh.
|
||||
shellcheck -e SC1090,SC2034 $(git ls-files '*.sh')
|
||||
|
||||
- name: ruff format drift (informational)
|
||||
# The canonical formatter is scripts/run_ruff_format.py
|
||||
# = ruff format + scripts/enforce_kwargs_spacing.py, so plain
|
||||
# `ruff format --check` reports the kwarg-spacing diff as
|
||||
# drift. Surface the count for visibility but keep
|
||||
# non-blocking until the custom pipeline is wired in here.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
ruff format --check unsloth unsloth_cli studio tests cli.py unsloth-cli.py
|
||||
410
.github/workflows/mlx-ci.yml
vendored
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Focused PR gate for the MLX dispatch surface, running on a real
|
||||
# Apple Silicon runner.
|
||||
#
|
||||
# Runner: macos-14 (M1, 3 vCPU / 7 GB / Apple Silicon standard runner
|
||||
# -- FREE for public repositories per the GitHub Actions billing
|
||||
# reference; larger variants like macos-14-large/-xlarge are paid so
|
||||
# we deliberately avoid those).
|
||||
#
|
||||
# Why a single Mac job (no Linux+spoof leg): the dispatch tests are
|
||||
# 100% spoofed monkeypatches and run identically on any host, so the
|
||||
# Linux leg was duplicating the matrix tests already covered on Mac
|
||||
# while missing everything Apple-specific. The Mac job runs the SAME
|
||||
# spoofed matrix PLUS three things only a real Apple Silicon host
|
||||
# can prove:
|
||||
#
|
||||
# 1. unsloth._IS_MLX flips True on Darwin+arm64 with mlx genuinely
|
||||
# installed (no spoof).
|
||||
# 2. Every PR-A MLX-only unsloth_zoo module (mlx_loader, mlx_trainer,
|
||||
# mlx_compile, mlx_utils, mlx_cce, gated_delta_vjp) imports
|
||||
# against the real `mlx` + `mlx-lm` + `mlx-vlm` PyPI wheels --
|
||||
# each does `import mlx.core as mx` at module top level, so this
|
||||
# catches a future change that breaks the real wheels without
|
||||
# needing a Mac developer in the loop.
|
||||
# 3. The hardware-dispatch spoofs do not collide with the real
|
||||
# environment (the test fixture installs a MetaPathFinder that
|
||||
# blocks `import mlx.core` for "no-mlx" profiles, faithfully
|
||||
# simulating a Mac without mlx even when mlx IS installed).
|
||||
# 4. End-to-end MLX training + inference smoke test:
|
||||
# run_real_mlx_smoke.py trains unsloth/gemma-3-270m-it for 7
|
||||
# deterministic LoRA steps on a single repeated text row, then
|
||||
# verifies the trained model can complete the prompt and that
|
||||
# losses + grad norms are finite and well-behaved. This is the
|
||||
# only place in CI that exercises a real MLX backward pass +
|
||||
# optimizer step + inference call.
|
||||
#
|
||||
# Three dispatch test files documented in tests/studio/README.md:
|
||||
# - test_hardware_dispatch_matrix.py parametrized 7-profile matrix
|
||||
# + 2 dispatch-priority canaries
|
||||
# - test_is_mlx_dispatch_gate.py AST + runtime guard on
|
||||
# unsloth._IS_MLX
|
||||
# - test_mlx_training_worker_behaviors.py AST contract checks on
|
||||
# studio/backend/core/training/worker.py
|
||||
#
|
||||
# Surfaces a single PR check ("MLX CI on Mac M1 / dispatch").
|
||||
#
|
||||
# Security audit footprint: every package this workflow installs is
|
||||
# already covered by .github/workflows/security-audit.yml -- the deps
|
||||
# come from studio/backend/requirements/studio.txt and unsloth-zoo's
|
||||
# pyproject (resolved transitively). The git+ install of unsloth-zoo
|
||||
# is intentionally skipped by the audit (pip-audit cannot resolve a
|
||||
# git URL through PyPI metadata; the audit comment in security-audit.yml
|
||||
# documents this). No new package is introduced solely by MLX CI.
|
||||
|
||||
name: MLX CI on Mac M1
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'unsloth/__init__.py'
|
||||
- 'unsloth/_gpu_init.py'
|
||||
- 'studio/backend/utils/hardware/**'
|
||||
- 'studio/backend/core/training/worker.py'
|
||||
- 'studio/backend/core/inference/mlx_inference.py'
|
||||
- 'tests/studio/test_hardware_dispatch_matrix.py'
|
||||
- 'tests/studio/test_is_mlx_dispatch_gate.py'
|
||||
- 'tests/studio/test_mlx_training_worker_behaviors.py'
|
||||
- 'tests/studio/run_real_mlx_smoke.py'
|
||||
- 'tests/conftest.py'
|
||||
- '.github/workflows/mlx-ci.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
name: dispatch
|
||||
runs-on: macos-14
|
||||
# 25 min: dispatch + spoofed matrix + 7-step real LoRA training is
|
||||
# under 2 min; GGUF export builds llama.cpp via cmake on Apple
|
||||
# Silicon (~5-7 min), so we budget headroom.
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
# macOS install ladder, validated locally against a Linux
|
||||
# mac-sim venv (platform spoofed + mlx_simulation shim + real
|
||||
# datasets/transformers/structlog).
|
||||
#
|
||||
# 1. studio/backend/requirements/studio.txt brings structlog,
|
||||
# fastapi, etc. The hardware probe imports structlog at
|
||||
# module top level.
|
||||
# 2. Same pytest / numpy / httpx stack the rest of the repo CI
|
||||
# uses.
|
||||
# 3. torch is explicitly installed: unsloth-zoo's pyproject
|
||||
# deliberately excludes torch on darwin+arm64 (mlx replaces
|
||||
# it for runtime use), but the dispatch tests spoof
|
||||
# torch.cuda / torch.xpu / torch.backends.mps via monkeypatch
|
||||
# and so the test process needs torch importable. We pull
|
||||
# from the PyTorch CPU index so Apple Silicon gets the
|
||||
# explicit cpu+MPS arm64 wheel rather than something the
|
||||
# default PyPI resolver might pick up. The CPU index hosts
|
||||
# macosx_*_arm64 wheels alongside the Linux x86_64 ones.
|
||||
# 4. unsloth-zoo from git main (NOT PyPI), WITH deps. PR-A's
|
||||
# MLX support landed after the most recent unsloth-zoo PyPI
|
||||
# release; the wheel still raises NotImplementedError on
|
||||
# Apple Silicon when device_type.get_device_type() runs
|
||||
# unguarded. Studio's own install.sh overlays unsloth-zoo
|
||||
# from git main for the same reason. Pulling deps lets pip
|
||||
# resolve the platform-conditional MLX-only wheels (mlx,
|
||||
# mlx-lm, mlx-vlm gated on darwin+arm64 in unsloth-zoo's
|
||||
# pyproject) AND the shared deps (datasets, transformers,
|
||||
# sentencepiece, ...) that unsloth's MLX branch loads via
|
||||
# dataprep/raw_text.py.
|
||||
# 5. unsloth -e . --no-deps so the editable install does not
|
||||
# fight the unsloth-zoo dep set.
|
||||
#
|
||||
# All explicit pip installs are version-pinned to a single
|
||||
# released version (the latest as of 2026-05-07 within each
|
||||
# project's existing constraint range). bump alongside the rest
|
||||
# of the security audit when a new release lands.
|
||||
- name: Install deps
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r studio/backend/requirements/studio.txt
|
||||
pip install \
|
||||
'python-multipart==0.0.27' \
|
||||
'aiofiles==25.1.0' \
|
||||
'sqlalchemy==2.0.49' \
|
||||
'cryptography==48.0.0' \
|
||||
'pyyaml==6.0.3' \
|
||||
'jinja2==3.1.6' \
|
||||
'mammoth==1.12.0' \
|
||||
'unpdf==1.0.0' \
|
||||
'requests==2.33.1' \
|
||||
'typer==0.25.1' \
|
||||
'numpy==2.4.4' \
|
||||
'pytest==9.0.3' \
|
||||
'pytest-asyncio==1.3.0' \
|
||||
'httpx==0.28.1'
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu \
|
||||
'torch==2.10.0'
|
||||
pip install "unsloth_zoo @ git+https://github.com/unslothai/unsloth-zoo"
|
||||
pip install -e . --no-deps
|
||||
|
||||
# Real Apple Silicon sanity: confirm _IS_MLX activates on real
|
||||
# hardware with no platform spoof.
|
||||
- name: Verify _IS_MLX flips True on real Apple Silicon
|
||||
run: |
|
||||
python -c "
|
||||
import platform
|
||||
assert platform.system() == 'Darwin', platform.system()
|
||||
assert platform.machine() == 'arm64', platform.machine()
|
||||
import unsloth
|
||||
assert unsloth._IS_MLX is True, f'expected _IS_MLX=True on real Apple Silicon, got {unsloth._IS_MLX}'
|
||||
print('OK: _IS_MLX activated on real Apple Silicon')
|
||||
"
|
||||
|
||||
# Real Apple Silicon sanity: confirm every PR-A MLX-only module
|
||||
# loads against real mlx + mlx-lm + mlx-vlm wheels.
|
||||
- name: Smoke-import every MLX-only unsloth_zoo module
|
||||
run: |
|
||||
python -c "
|
||||
import importlib
|
||||
for name in [
|
||||
'unsloth_zoo.mlx_loader',
|
||||
'unsloth_zoo.mlx_trainer',
|
||||
'unsloth_zoo.mlx_compile',
|
||||
'unsloth_zoo.mlx_utils',
|
||||
'unsloth_zoo.mlx_cce',
|
||||
'unsloth_zoo.gated_delta_vjp',
|
||||
]:
|
||||
importlib.import_module(name)
|
||||
print('OK:', name)
|
||||
from unsloth_zoo.mlx_loader import FastMLXModel
|
||||
from unsloth_zoo.mlx_trainer import MLXTrainer, MLXTrainingConfig
|
||||
assert hasattr(FastMLXModel, 'from_pretrained')
|
||||
print('OK: FastMLXModel + MLXTrainer surface present')
|
||||
"
|
||||
|
||||
# Spoofed dispatch matrix. Runs on the real Mac too -- the
|
||||
# test fixture installs a MetaPathFinder that blocks
|
||||
# `import mlx.core` for "no-mlx" profiles, so the spoofs
|
||||
# faithfully simulate every supported hardware combo regardless
|
||||
# of whether mlx is installed for real.
|
||||
- name: MLX dispatch tests (3 files, 36 tests)
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/studio
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
run: |
|
||||
python -m pytest -v --tb=short \
|
||||
tests/studio/test_hardware_dispatch_matrix.py \
|
||||
tests/studio/test_is_mlx_dispatch_gate.py \
|
||||
tests/studio/test_mlx_training_worker_behaviors.py
|
||||
|
||||
# Studio prebuilt llama.cpp install + GGUF inference. Drives the
|
||||
# exact path Studio's setup.sh takes on macOS: invokes
|
||||
# studio/install_llama_prebuilt.py with --published-repo
|
||||
# ggml-org/llama.cpp and --published-release-tag b9049 (the
|
||||
# latest llama.cpp release at the time this step was added; bump
|
||||
# via UNSLOTH_LLAMA_TAG / DEFAULT_LLAMA_TAG when refreshing).
|
||||
# The installer downloads llama-b9049-bin-macos-arm64.tar.gz,
|
||||
# which is the universal Apple Silicon (arm64) build -- the
|
||||
# same artifact works on M1/M2/M3/M4 because llama.cpp compiles
|
||||
# against the ARMv8.2 baseline.
|
||||
#
|
||||
# The b9049 release also publishes:
|
||||
# - llama-b9049-bin-macos-arm64-kleidiai.tar.gz
|
||||
# KleidiAI dispatches at runtime; on M1 it falls back where
|
||||
# ISA features (e.g. I8MM) are missing, so this asset also
|
||||
# runs on M1 -- Studio just doesn't choose it by default.
|
||||
# - llama-b9049-bin-macos-x64.tar.gz
|
||||
# Intel-only; would only run on M1 via Rosetta 2 emulation,
|
||||
# which we explicitly avoid.
|
||||
# - iOS XCFramework
|
||||
# iOS-app build artifact, unrelated to a macOS desktop CI.
|
||||
#
|
||||
# After install, downloads a small published GGUF
|
||||
# (unsloth/gemma-3-270m-it-GGUF, Q4_K_M) from HuggingFace and
|
||||
# runs the prebuilt llama-cli on it. Asserts the prompt echo
|
||||
# appears in stdout. If the install fails OR the binary exits
|
||||
# non-zero, that's an Unsloth/Studio bug.
|
||||
- name: Studio prebuilt llama.cpp install + GGUF inference (Mac M1)
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# install_llama_prebuilt.py hits the GitHub releases API to
|
||||
# resolve the asset URL. Anonymous calls share the runner-IP
|
||||
# rate-limit bucket and 403 quickly -- pass the workflow's
|
||||
# automatic GITHUB_TOKEN to bump us to the 5000/hr authenticated
|
||||
# bucket.
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp"
|
||||
rm -rf "$INSTALL_DIR"
|
||||
# --simple-policy is required when --published-repo points
|
||||
# at upstream ggml-org/llama.cpp; that repo doesn't ship the
|
||||
# llama-prebuilt-manifest.json asset Studio's default policy
|
||||
# expects, so the simple platform-specific policy maps
|
||||
# Darwin+arm64 -> bin-macos-arm64 directly. studio/setup.sh
|
||||
# passes both --published-repo ggml-org/llama.cpp AND
|
||||
# --simple-policy automatically on macOS, so this CI step
|
||||
# exercises the same code path users hit when they run
|
||||
# `curl -fsSL https://unsloth.ai/install.sh | sh`.
|
||||
python studio/install_llama_prebuilt.py \
|
||||
--install-dir "$INSTALL_DIR" \
|
||||
--published-repo ggml-org/llama.cpp \
|
||||
--published-release-tag b9049 \
|
||||
--simple-policy
|
||||
|
||||
# Studio bundles only llama-server + llama-quantize from the
|
||||
# prebuilt (not llama-cli) -- inference goes through
|
||||
# llama-server's HTTP /completion endpoint. Validate both:
|
||||
# llama-quantize --help proves the dynamic libs link, then
|
||||
# spin up llama-server and POST a /completion request on a
|
||||
# tiny published GGUF.
|
||||
LLAMA_SERVER="$INSTALL_DIR/build/bin/llama-server"
|
||||
LLAMA_QUANT="$INSTALL_DIR/build/bin/llama-quantize"
|
||||
[ -x "$LLAMA_SERVER" ] || { echo "::error::llama-server missing at $LLAMA_SERVER"; find "$INSTALL_DIR/build" -type f | head -40; exit 1; }
|
||||
[ -x "$LLAMA_QUANT" ] || { echo "::error::llama-quantize missing at $LLAMA_QUANT"; exit 1; }
|
||||
echo "llama-server : $LLAMA_SERVER"
|
||||
echo "llama-quantize: $LLAMA_QUANT"
|
||||
"$LLAMA_QUANT" --help >/dev/null && echo " llama-quantize loads OK"
|
||||
|
||||
mkdir -p /tmp/ggufs
|
||||
python -c "
|
||||
from huggingface_hub import hf_hub_download
|
||||
p = hf_hub_download(
|
||||
'unsloth/gemma-3-270m-it-GGUF',
|
||||
'gemma-3-270m-it-Q4_K_M.gguf',
|
||||
local_dir = '/tmp/ggufs',
|
||||
)
|
||||
print('downloaded:', p)
|
||||
"
|
||||
|
||||
PORT=18080
|
||||
echo "=== starting llama-server on 127.0.0.1:$PORT ==="
|
||||
"$LLAMA_SERVER" \
|
||||
-m /tmp/ggufs/gemma-3-270m-it-Q4_K_M.gguf \
|
||||
--host 127.0.0.1 \
|
||||
--port "$PORT" \
|
||||
-c 256 \
|
||||
-n 16 \
|
||||
--no-warmup \
|
||||
> /tmp/llama-server.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT
|
||||
|
||||
# Wait for /health to come up
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
|
||||
echo " server up after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if ! curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
|
||||
echo "::error::llama-server never became healthy"
|
||||
tail -40 /tmp/llama-server.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PROMPT="Hello, my name is"
|
||||
echo "=== POST /completion ==="
|
||||
RESP=$(curl -sf -X POST "http://127.0.0.1:$PORT/completion" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"prompt\":\"$PROMPT\",\"n_predict\":16,\"temperature\":0,\"seed\":3407}")
|
||||
echo "raw response (head): $(echo "$RESP" | head -c 600)"
|
||||
CONTENT=$(echo "$RESP" | python -c "import json,sys; print(json.loads(sys.stdin.read()).get('content',''))")
|
||||
echo "completion content: $CONTENT"
|
||||
|
||||
if [ -z "$CONTENT" ]; then
|
||||
echo "::error::llama-server /completion returned empty content"
|
||||
tail -40 /tmp/llama-server.log
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: Studio prebuilt llama.cpp on Mac M1 + GGUF /completion works"
|
||||
|
||||
# Real MLX training + inference smoke test. Trains
|
||||
# unsloth/gemma-3-270m-it for 7 deterministic LoRA steps
|
||||
# (batch_size=2, gradient_accumulation_steps=3) on a single
|
||||
# repeated row ("<<HELLO!!>> My name is Unsloth!"), then saves
|
||||
# the trained model in 3 export formats. The `train` subcommand
|
||||
# captures per-phase timing + peak GPU + peak RSS into
|
||||
# train_metrics.json so we can detect regressions across CI runs.
|
||||
- name: MLX export round-trip — TRAIN + SAVE 3 formats
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
run: |
|
||||
mkdir -p mlx_workdir
|
||||
python tests/studio/run_real_mlx_smoke.py train \
|
||||
--workdir "$PWD/mlx_workdir"
|
||||
|
||||
# Each reload step runs in a FRESH Python process to confirm
|
||||
# the cold-start path users would hit in production also works
|
||||
# (not just the in-memory continuation of a still-running
|
||||
# trainer). FastMLXModel.from_pretrained gets called from
|
||||
# scratch; mx.random is re-seeded; per-step timing + peak
|
||||
# memory are emitted to {format}_reload_metrics.json next to
|
||||
# the saved dir.
|
||||
- name: MLX export round-trip — RELOAD LoRA (fresh process)
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
run: |
|
||||
python tests/studio/run_real_mlx_smoke.py reload \
|
||||
--format lora \
|
||||
--dir "$PWD/mlx_workdir/lora"
|
||||
|
||||
- name: MLX export round-trip — RELOAD merged_16bit (fresh process)
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
run: |
|
||||
python tests/studio/run_real_mlx_smoke.py reload \
|
||||
--format merged \
|
||||
--dir "$PWD/mlx_workdir/merged_16bit"
|
||||
|
||||
# GGUF reload uses the llama-cli binary that save_pretrained_gguf
|
||||
# built. If save_pretrained_gguf was skipped during train (e.g.
|
||||
# llama.cpp's convert_hf_to_gguf asserts on the model's tokenizer
|
||||
# vocab -- a downstream llama.cpp limitation, not an unsloth_zoo
|
||||
# bug), this step emits a workflow warning and exits 0 so the
|
||||
# LoRA + merged_16bit assertions remain the gating signal.
|
||||
- name: MLX export round-trip — RELOAD GGUF via llama-cli (fresh process)
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
run: |
|
||||
if python -c "import json,sys; m=json.load(open('mlx_workdir/train_metrics.json')); sys.exit(0 if m.get('gguf_supported') else 1)"; then
|
||||
python tests/studio/run_real_mlx_smoke.py reload \
|
||||
--format gguf \
|
||||
--dir "$PWD/mlx_workdir/gguf"
|
||||
else
|
||||
REASON=$(python -c "import json; m=json.load(open('mlx_workdir/train_metrics.json')); print(m.get('gguf_skip_reason') or 'unknown')")
|
||||
echo "::warning title=GGUF round-trip skipped::${REASON}"
|
||||
echo "GGUF export was skipped during the train phase. Reason:"
|
||||
echo " ${REASON}"
|
||||
echo "Continuing without failing the job; the LoRA + merged_16bit"
|
||||
echo "reload assertions are still gating this PR."
|
||||
fi
|
||||
|
||||
# Print all metrics JSON files so regressions are visible in the
|
||||
# job log. always() so we get telemetry even if a reload step
|
||||
# asserted gibberish.
|
||||
- name: MLX export round-trip — aggregate metrics
|
||||
if: always()
|
||||
run: |
|
||||
for f in mlx_workdir/train_metrics.json \
|
||||
mlx_workdir/lora_reload_metrics.json \
|
||||
mlx_workdir/merged_reload_metrics.json \
|
||||
mlx_workdir/gguf_reload_metrics.json; do
|
||||
echo "=== $f ==="
|
||||
cat "$f" 2>/dev/null || echo "(missing)"
|
||||
echo
|
||||
done
|
||||
382
.github/workflows/notebooks-ci.yml
vendored
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Cross-repo notebook validator. Lives in unslothai/unsloth (this repo)
|
||||
# and inspects every notebook in unslothai/notebooks at HEAD (or the
|
||||
# ref dispatched in via repository_dispatch).
|
||||
#
|
||||
# Catches the bug classes that landed in:
|
||||
# - unslothai/notebooks#258 Colab torchao 0.10 vs peft 0.19 floor
|
||||
# - unslothai/notebooks#260 DONT_UPDATE_EXCEPTIONS coverage drift
|
||||
# - unslothai/notebooks#261 torch/torchcodec ABI; --no-deps tokenizers
|
||||
# - unslothai/notebooks#264 --no-deps transformers + Colab tokenizers drift
|
||||
# - unslothai/notebooks#221 git+ HEAD installs in install cells
|
||||
# - unslothai/notebooks commit 51b1462 template/notebook drift
|
||||
#
|
||||
# CPU-only by design. Layer 2 (api-introspect) reuses the existing
|
||||
# tests/_zoo_aggressive_cuda_spoof.py harness so `import unsloth`
|
||||
# succeeds on a GPU-less ubuntu-latest runner.
|
||||
|
||||
name: Notebooks CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'unsloth/**'
|
||||
- 'scripts/notebook_validator.py'
|
||||
- 'scripts/notebook_to_python.py'
|
||||
- 'scripts/data/colab_pip_freeze.gpu.txt'
|
||||
- 'scripts/data/colab_to_cpu_pin.json'
|
||||
- 'tests/notebooks/**'
|
||||
- 'tests/_zoo_aggressive_cuda_spoof.py'
|
||||
- '.github/workflows/notebooks-ci.yml'
|
||||
schedule:
|
||||
# Daily 06:17 UTC. Catches Colab preinstall bumps (the upstream image
|
||||
# is rebuilt roughly weekly) without us waiting on a PR. Off the
|
||||
# :00/:30 fleet-collision spots.
|
||||
- cron: '17 6 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
notebooks_ref:
|
||||
description: 'unslothai/notebooks ref to lint (branch / SHA / tag)'
|
||||
default: 'main'
|
||||
include_smoke:
|
||||
description: 'Also run the install-cell smoke matrix (longer)'
|
||||
type: boolean
|
||||
default: false
|
||||
repository_dispatch:
|
||||
# Fired by a tiny companion workflow on unslothai/notebooks.
|
||||
types: [notebooks_pr_opened, notebooks_main_pushed]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NOTEBOOKS_REF: >-
|
||||
${{ github.event.inputs.notebooks_ref ||
|
||||
github.event.client_payload.ref ||
|
||||
'main' }}
|
||||
|
||||
jobs:
|
||||
static:
|
||||
name: static (drift + lint + exceptions)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout unsloth (this PR)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
path: unsloth
|
||||
|
||||
- name: Checkout unslothai/notebooks @ ${{ env.NOTEBOOKS_REF }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: unslothai/notebooks
|
||||
ref: ${{ env.NOTEBOOKS_REF }}
|
||||
path: notebooks
|
||||
fetch-depth: 0 # drift check needs git status / diff
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install validator deps
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# nbformat + nbconvert come from the converter's requirements;
|
||||
# spellchecker + huggingface_hub are imported at module top of
|
||||
# update_all_notebooks.py.
|
||||
pip install \
|
||||
'nbformat>=5.10' 'nbconvert>=7.16' 'pyspellchecker>=0.8' \
|
||||
'huggingface_hub>=0.34' 'tqdm>=4.66'
|
||||
|
||||
- name: Refresh Colab pip-freeze (best-effort; falls back to snapshot)
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py refresh-colab \
|
||||
--out unsloth/scripts/data/colab_pip_freeze.gpu.txt \
|
||||
|| echo "::warning::refresh-colab failed; using committed snapshot"
|
||||
|
||||
- name: Diff Colab oracle vs committed snapshots (advisory)
|
||||
# Pulls pip-freeze.gpu.txt + apt-list-gpu.txt + os-info-gpu.txt
|
||||
# from googlecolab/backend-info and prints NEW / REMOVED /
|
||||
# CHANGED entries against scripts/data/colab_*.txt. Non-blocking
|
||||
# on PRs; the daily cron job below runs the same step with
|
||||
# --strict so upstream rotations surface within ~24h.
|
||||
continue-on-error: true
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py colab-diff \
|
||||
--snapshot-dir unsloth/scripts/data
|
||||
|
||||
- name: Drift check (re-run update_all_notebooks.py + git diff)
|
||||
working-directory: ${{ github.workspace }}
|
||||
# Reported as non-blocking until the upstream `unslothai/notebooks`
|
||||
# tree is regenerated. The first run on @main surfaces ~463 files
|
||||
# of drift (7359 / 9634 line delta), which is a real backlog the
|
||||
# notebooks-side maintainers need to clear in their own repo --
|
||||
# this PR's role is to surface the count, not auto-fix it.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py drift \
|
||||
--notebooks-dir notebooks
|
||||
|
||||
- name: Convert sanity (every nb / kaggle / original_template -> .py)
|
||||
# Same rationale as Drift: a handful of upstream notebooks fail
|
||||
# the converter (custom magics, malformed JSON, etc). Surface
|
||||
# the count without blocking; the team triages in unslothai/notebooks.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py convert \
|
||||
--notebooks-dir notebooks \
|
||||
--out _converted
|
||||
|
||||
- name: Lint (install cells + AST scan, env-scoped)
|
||||
# Reported as non-blocking (continue-on-error: true) until the
|
||||
# backlog of pre-existing findings on unslothai/notebooks@main is
|
||||
# cleared. Same pattern PR #5298 used for biome:check on the
|
||||
# frontend. As of this commit the live tree surfaces 27 errors +
|
||||
# 6 warnings, all real (peft/torchao floor missing in 6 nb/
|
||||
# notebooks, 14 git+ HEAD installs in hand-tuned exception
|
||||
# notebooks, 6 torch/torchcodec ABI mismatches, 1
|
||||
# transformers/tokenizers --no-deps drift). The count surfaces
|
||||
# in the PR check UI. Drop continue-on-error once it hits zero.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py lint \
|
||||
--notebooks-dir notebooks \
|
||||
--colab-pin unsloth/scripts/data/colab_pip_freeze.gpu.txt \
|
||||
--no-pypi
|
||||
# --no-pypi skips R-INST-002 (transitive resolve via PyPI metadata).
|
||||
# Layer 1 keeps PR-time wall-clock predictable; the daily cron run
|
||||
# below drops --no-pypi and refreshes the cache.
|
||||
|
||||
- name: DONT_UPDATE_EXCEPTIONS coverage
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py exceptions \
|
||||
--notebooks-dir notebooks
|
||||
|
||||
static-with-pypi:
|
||||
name: static + transitive resolve (cron / dispatch only)
|
||||
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with: { path: unsloth }
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: unslothai/notebooks
|
||||
ref: ${{ env.NOTEBOOKS_REF }}
|
||||
path: notebooks
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with: { python-version: '3.12', cache: 'pip' }
|
||||
- name: Install
|
||||
run: pip install -U pip
|
||||
- name: Refresh Colab oracle
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py refresh-colab \
|
||||
--out unsloth/scripts/data/colab_pip_freeze.gpu.txt
|
||||
- name: Diff Colab oracle vs committed snapshots (--strict on cron)
|
||||
# Cron-only escalation of the advisory PR-time check. Fails if
|
||||
# any of pip-freeze.gpu.txt / apt-list-gpu.txt / os-info-gpu.txt
|
||||
# has drifted from scripts/data/colab_*.txt; refresh the
|
||||
# snapshots in this repo to acknowledge.
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py colab-diff \
|
||||
--snapshot-dir unsloth/scripts/data --strict
|
||||
- name: Lint with live PyPI metadata
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py lint \
|
||||
--notebooks-dir notebooks \
|
||||
--colab-pin unsloth/scripts/data/colab_pip_freeze.gpu.txt
|
||||
|
||||
api-introspect:
|
||||
name: api surface (under CUDA spoof)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 12
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with: { path: unsloth }
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: unslothai/notebooks
|
||||
ref: ${{ env.NOTEBOOKS_REF }}
|
||||
path: notebooks
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with: { python-version: '3.12', cache: 'pip' }
|
||||
|
||||
- name: Install CPU torch + pinned unsloth + trl + converter deps
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# CPU torch + torchvision. torchvision is required because
|
||||
# unsloth_zoo.vision_utils imports PIL at module top, and the
|
||||
# easiest way to get a torch-compatible PIL on a CPU runner is
|
||||
# to let torchvision pull the right Pillow version.
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu \
|
||||
'torch>=2.8,<2.11' 'torchvision<0.26'
|
||||
# Pin to the same versions update_all_notebooks.py installs in
|
||||
# generated notebooks. Keep these in lockstep with PIN_TRL /
|
||||
# PIN_TRANSFORMERS in unslothai/notebooks/update_all_notebooks.py.
|
||||
# `triton` is added because unsloth/_gpu_init.py:232 does an
|
||||
# unconditional `import triton`; the PyPI wheel installs cleanly
|
||||
# on Linux x86_64 even without CUDA (same rationale as
|
||||
# consolidated-tests-ci.yml line 192-205).
|
||||
# Pillow is listed explicitly as a defensive belt-and-braces
|
||||
# next to torchvision (vision_utils crashes ModuleNotFoundError
|
||||
# if torchvision skipped its Pillow dep for any reason).
|
||||
pip install 'transformers>=4.56,<5.6' 'trl>=0.22,<0.26' 'accelerate>=1.0' \
|
||||
'datasets>=3.4,<5' 'peft>=0.15,<0.20' \
|
||||
'bitsandbytes>=0.43' 'sentencepiece' 'protobuf' triton \
|
||||
Pillow safetensors tqdm packaging psutil
|
||||
# Converter deps (nbformat for notebook_to_python.py).
|
||||
pip install 'nbformat>=5.10' 'nbconvert>=7.16'
|
||||
# Install unsloth from the LOCAL checkout (the PR head), not PyPI.
|
||||
# The PR-time CI must validate the code in this PR; PyPI unsloth
|
||||
# may lag the in-repo CPU-torch fallback in unsloth/kernels/utils.py
|
||||
# (lines 162-170) that handles missing torch._C._cuda_getCurrentRawStream.
|
||||
pip install --no-deps unsloth_zoo
|
||||
pip install --no-deps -e ./unsloth
|
||||
|
||||
- name: Convert notebooks for AST scan
|
||||
# Same upstream-conversion-error tolerance as the static job.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py convert \
|
||||
--notebooks-dir notebooks --out _converted
|
||||
|
||||
- name: Dump unsloth + trl API surface (under CUDA spoof)
|
||||
run: |
|
||||
PYTHONPATH=unsloth/tests python -u - <<'PY'
|
||||
import sys, json, inspect
|
||||
import _zoo_aggressive_cuda_spoof as _spoof
|
||||
_spoof.apply()
|
||||
import unsloth
|
||||
import trl
|
||||
surface = {}
|
||||
for cls_name in ("FastLanguageModel", "FastVisionModel", "FastModel"):
|
||||
cls = getattr(unsloth, cls_name, None)
|
||||
if cls is None:
|
||||
continue
|
||||
surface[cls_name] = sorted(n for n in dir(cls) if not n.startswith("_"))
|
||||
surface["SFTConfig_kwargs"] = sorted(inspect.signature(trl.SFTConfig.__init__).parameters)
|
||||
json.dump(surface, open("_api_surface.json", "w"), indent=2)
|
||||
print("dumped surface for:", list(surface))
|
||||
PY
|
||||
|
||||
- name: Run API rule against converted notebooks
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py api \
|
||||
--converted-dir _converted \
|
||||
--surface _api_surface.json
|
||||
|
||||
smoke-install:
|
||||
name: smoke install (Colab-shaped venv, opt-in)
|
||||
if: ${{ github.event.inputs.include_smoke == 'true' || github.event_name == 'schedule' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# One representative notebook per installation_*_content template.
|
||||
# Add rows when a new install template lands in update_all_notebooks.py.
|
||||
notebook:
|
||||
- 'nb/Llama3.1_(8B)-Alpaca.ipynb' # installation_content
|
||||
- 'nb/Gemma3_(4B)-Vision.ipynb' # installation_content + vision
|
||||
- 'nb/Llama3.1_(8B)-GRPO.ipynb' # installation_extra_grpo_content
|
||||
- 'nb/gpt-oss-(20B)-Fine-tuning.ipynb' # installation_gpt_oss_content
|
||||
- 'nb/Qwen3_5_(4B)_Vision.ipynb' # installation_qwen3_5_content
|
||||
- 'nb/Nemotron-3-Nano-30B-A3B_A100.ipynb' # installation_nemotron_nano_content
|
||||
- 'nb/Whisper.ipynb' # installation_whisper_content
|
||||
- 'nb/Synthetic_Data_Hackathon.ipynb' # installation_synthetic_data_content
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with: { path: unsloth }
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: unslothai/notebooks
|
||||
ref: ${{ env.NOTEBOOKS_REF }}
|
||||
path: notebooks
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with: { python-version: '3.12' }
|
||||
|
||||
- name: Seed Colab-shaped venv from pip-freeze (CPU-mapped)
|
||||
run: |
|
||||
# Strip cu128 local versions, route torch/torchvision to the CPU
|
||||
# wheel index, drop CUDA-specific deps the runner can't use.
|
||||
python -u - <<'PY' > /tmp/seed_pins.txt
|
||||
import json, re
|
||||
mapping = json.load(open("unsloth/scripts/data/colab_to_cpu_pin.json"))
|
||||
rewrite = mapping["rewrite"]
|
||||
skip = set(mapping["skip"])
|
||||
spoof = set(mapping["module_spoof"])
|
||||
out = []
|
||||
for line in open("unsloth/scripts/data/colab_pip_freeze.gpu.txt"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
m = re.match(r"^([A-Za-z0-9._-]+)\s*==\s*(.+)$", line)
|
||||
if not m:
|
||||
continue
|
||||
name, ver = m.group(1).lower(), m.group(2)
|
||||
if name in skip:
|
||||
continue
|
||||
if name in spoof:
|
||||
continue
|
||||
if name in rewrite:
|
||||
ver = re.sub(r"[+\-].+$", "", ver)
|
||||
out.append(f"{name}=={ver}")
|
||||
else:
|
||||
ver = re.sub(r"[+\-].+$", "", ver)
|
||||
out.append(f"{name}=={ver}")
|
||||
print("\n".join(out))
|
||||
PY
|
||||
head -5 /tmp/seed_pins.txt
|
||||
wc -l /tmp/seed_pins.txt
|
||||
|
||||
- name: Install Colab-shaped venv
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Best-effort: any single line that fails to resolve on CPU is
|
||||
# tolerated; the smoke contract is "the install cell + the unsloth
|
||||
# import works", not "the entire Colab venv reproduces."
|
||||
while IFS= read -r spec; do
|
||||
pip install "$spec" --index-url https://download.pytorch.org/whl/cpu \
|
||||
--extra-index-url https://pypi.org/simple || \
|
||||
echo "::warning::pin failed: $spec"
|
||||
done < /tmp/seed_pins.txt
|
||||
|
||||
- name: Run install cell
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py convert \
|
||||
--notebooks-dir notebooks --out _converted
|
||||
# Take the converted .py and run the install cell only.
|
||||
BASE="$(basename '${{ matrix.notebook }}' .ipynb | tr -d '()' | tr -c '[:alnum:]_' _)"
|
||||
PY="_converted/${BASE}.py"
|
||||
[ -f "$PY" ] || { echo "::error::$PY not found"; ls _converted | head; exit 1; }
|
||||
# Truncate at the first `from unsloth import` so we run install +
|
||||
# core imports only.
|
||||
awk '/^from unsloth import/ { print "import sys; sys.exit(0)"; exit } { print }' "$PY" > _smoke.py
|
||||
PYTHONPATH=unsloth/tests python -u - <<'PY'
|
||||
import _zoo_aggressive_cuda_spoof as _s; _s.apply()
|
||||
# Stub torchcodec for cells that import it — no CPU wheel exists.
|
||||
import sys, types
|
||||
if "torchcodec" not in sys.modules:
|
||||
sys.modules["torchcodec"] = types.ModuleType("torchcodec")
|
||||
exec(open("_smoke.py").read(), {"__name__": "__main__"})
|
||||
PY
|
||||
|
||||
- name: Verify imports under spoof
|
||||
run: |
|
||||
PYTHONPATH=unsloth/tests python -u - <<'PY'
|
||||
import sys, types
|
||||
if "torchcodec" not in sys.modules:
|
||||
sys.modules["torchcodec"] = types.ModuleType("torchcodec")
|
||||
import _zoo_aggressive_cuda_spoof as _s; _s.apply()
|
||||
import unsloth, peft, torch, torchao, transformers, tokenizers
|
||||
print("OK: imports pass under CUDA spoof")
|
||||
PY
|
||||
796
.github/workflows/security-audit.yml
vendored
Normal file
|
|
@ -0,0 +1,796 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Multi-language supply-chain audit. Triggers:
|
||||
# - PRs touching any dependency manifest (Python / npm / Cargo) or
|
||||
# this workflow file,
|
||||
# - push to main / pip,
|
||||
# - nightly @ 04:13 UTC so newly-published advisories surface even
|
||||
# when no PR opens,
|
||||
# - workflow_dispatch for ad-hoc invocations.
|
||||
#
|
||||
# Two jobs:
|
||||
# - advisory-audit: one runner that runs pip-audit + npm audit +
|
||||
# cargo audit back-to-back. All three are
|
||||
# advisory-DB lookups -- fast, lockfile-driven,
|
||||
# no archive download. Setting up the python /
|
||||
# node / rust toolchains on one runner and
|
||||
# running the three commands serially is
|
||||
# cheaper than spinning up three runners.
|
||||
# - pip-scan-packages: 3-shard matrix that downloads + pattern-scans
|
||||
# every PyPI archive in the transitive closure.
|
||||
# This is the expensive job (~6 min/shard,
|
||||
# running in parallel) and it must stay
|
||||
# independent so a CVE-DB hit in advisory-audit
|
||||
# does not block the supply-chain pattern scan
|
||||
# (or vice versa).
|
||||
#
|
||||
# All steps are non-blocking initially. The default branch already
|
||||
# carries a known-vuln backlog (the dependabot banner shows 17 today,
|
||||
# pip-audit catches 2 more, npm/cargo will catch their own); a hard
|
||||
# gate now would block every PR on a baseline we have not triaged.
|
||||
# As each baseline closes, drop continue-on-error per step.
|
||||
#
|
||||
# Dependency coverage:
|
||||
# - unsloth core (pyproject.toml [project.dependencies])
|
||||
# - unsloth `huggingfacenotorch` extras (the canonical install path
|
||||
# for fine-tuning users; pulls transformers / peft / accelerate /
|
||||
# trl / datasets / diffusers / sentence-transformers / etc.)
|
||||
# - all six Studio backend requirements files
|
||||
# - Studio frontend (npm) and Tauri shell (cargo)
|
||||
# Each Python step builds a filtered dep list from pyproject.toml +
|
||||
# requirements/*.txt before auditing. We do NOT install any of these
|
||||
# -- pip-audit resolves through PyPI metadata, scan_packages.py
|
||||
# downloads sdist/wheel archives and inspects them without running
|
||||
# install hooks, so an attacker who has compromised a transitive dep
|
||||
# cannot execute code in this workflow.
|
||||
|
||||
name: Security audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/backend/requirements/**'
|
||||
- 'studio/frontend/package.json'
|
||||
- 'studio/frontend/package-lock.json'
|
||||
- 'studio/src-tauri/Cargo.toml'
|
||||
- 'studio/src-tauri/Cargo.lock'
|
||||
- 'pyproject.toml'
|
||||
- 'scripts/scan_packages.py'
|
||||
- '.github/workflows/security-audit.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
schedule:
|
||||
- cron: '13 4 * * *' # 04:13 UTC daily, off the cron rush
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Combined advisory-DB audit: pip-audit + npm audit + cargo audit
|
||||
# all on one runner. Each step is continue-on-error so a finding in
|
||||
# one toolchain does not suppress the others.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
advisory-audit:
|
||||
name: advisory audit (pip + npm + cargo)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
# step-security/harden-runner installs an eBPF-based egress
|
||||
# firewall on the runner. In `audit` mode it logs every outbound
|
||||
# connection without blocking; in `block` mode it rejects
|
||||
# anything outside `allowed-endpoints`. We run audit-only
|
||||
# initially: the next time this job hits a real PyPI advisory or
|
||||
# an attacker-funded archive in pip-scan-packages, the audit log
|
||||
# tells us exactly which hosts were dialed and we promote the
|
||||
# allowlist to block. Would have *contained* the litellm exfil
|
||||
# even if scan_packages had missed the .pth payload.
|
||||
# SHA-pinned (not @v2): the litellm 1.82.7 attack chain hijacked
|
||||
# mutable tags on aquasecurity/trivy-action and would have hit
|
||||
# anyone using @v0 / @v2 / @latest references. Pinning to a 40-
|
||||
# char SHA freezes this action at known-good code; Dependabot's
|
||||
# github-actions ecosystem will auto-bump the SHA.
|
||||
# v2.19.1 commit:
|
||||
- name: Harden runner (egress audit)
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
disable-sudo: true
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Full history so TruffleHog can diff base..head; without
|
||||
# this it sees only the latest commit and reports nothing.
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27
|
||||
|
||||
- uses: swatinem/rust-cache@23869a5bd66c73db3c0ac40331f3206eb23791dc # v2.9.1
|
||||
with:
|
||||
workspaces: studio/src-tauri -> target
|
||||
|
||||
- name: Install pip-audit + cargo-audit
|
||||
# cargo-audit pulls advisories from the RustSec advisory-db on
|
||||
# first run and caches them under ~/.cargo/advisory-db. Pin
|
||||
# --locked so the version we install matches Cargo.lock
|
||||
# determinism. cargo-audit 0.22 supports the CVSS 4.0 schema
|
||||
# used in 2026 advisories (e.g. RUSTSEC-2026-0073); 0.21
|
||||
# crashes with a TOML parse error on that file.
|
||||
# npm audit is bundled with the node toolchain, no install.
|
||||
run: |
|
||||
python -m pip install --upgrade pip 'pip-audit>=2.7'
|
||||
cargo install --locked --version '^0.22' cargo-audit
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Python: pip-audit
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
- name: Build filtered Python requirements set
|
||||
# Two transforms:
|
||||
# (1) Generate audit-reqs/unsloth-deps.txt from pyproject.toml
|
||||
# so pip-audit sees the unsloth pip package's own dep set
|
||||
# (core + huggingfacenotorch extras: transformers / peft /
|
||||
# accelerate / trl / datasets / diffusers /
|
||||
# sentence-transformers / huggingface_hub / hf_transfer /
|
||||
# etc.).
|
||||
# (2) Copy each studio/backend/requirements/*.txt into
|
||||
# audit-reqs/ with `git+` lines stripped. pip-audit's `-r`
|
||||
# mode does a dry-run resolve against PyPI metadata; a
|
||||
# `git+https://...` spec forces it to clone, which is
|
||||
# both slow and outside the threat model (we audit
|
||||
# PyPI-served archives; a git ref is whatever HEAD says
|
||||
# on the runner). A comment line is left in place so the
|
||||
# skipped specs are obvious in the artifact.
|
||||
# The `huggingface` extra is `huggingfacenotorch` plus torch /
|
||||
# torchvision / triton, deliberately skipped: Studio backend
|
||||
# already pins a torch and the +cu* / +cpu local-version tags
|
||||
# trip up the PyPI resolver in `-r` mode.
|
||||
run: |
|
||||
mkdir -p audit-reqs
|
||||
python <<'PY' > audit-reqs/unsloth-deps.txt
|
||||
import tomllib
|
||||
with open("pyproject.toml", "rb") as f:
|
||||
d = tomllib.load(f)
|
||||
core = d["project"]["dependencies"]
|
||||
extras = d["project"]["optional-dependencies"]["huggingfacenotorch"]
|
||||
print("# Auto-generated from pyproject.toml by security-audit.yml.")
|
||||
print("# core deps + huggingfacenotorch extras.")
|
||||
for spec in core + extras:
|
||||
print(spec)
|
||||
PY
|
||||
for f in studio.txt extras.txt extras-no-deps.txt \
|
||||
no-torch-runtime.txt overrides.txt triton-kernels.txt; do
|
||||
python <<PY > "audit-reqs/$f"
|
||||
src = "studio/backend/requirements/$f"
|
||||
with open(src) as fh:
|
||||
for line in fh:
|
||||
stripped = line.strip()
|
||||
before_comment = stripped.split("#", 1)[0]
|
||||
if "git+" in before_comment:
|
||||
print(f"# [security-audit] skipped git+ spec: {stripped}")
|
||||
continue
|
||||
print(line.rstrip("\n"))
|
||||
PY
|
||||
done
|
||||
|
||||
- name: pip-audit (declared Python deps, no install)
|
||||
# `-r requirements.txt` resolves the requirements through pip's
|
||||
# dependency resolver against PyPI metadata and audits the
|
||||
# resolved tree without ever executing setup.py / install
|
||||
# hooks. Way faster than installing the full Studio runtime
|
||||
# and -- critically -- safer: an attacker who has compromised
|
||||
# a transitive dep cannot run code in this job.
|
||||
#
|
||||
# extras.txt + extras-no-deps.txt have legacy setup.py
|
||||
# packages (notably openai-whisper) whose setup.py imports
|
||||
# `pkg_resources`, which the isolated build env's current
|
||||
# setuptools no longer ships. PIP_CONSTRAINT pins an older
|
||||
# setuptools into the build env so those builds resolve.
|
||||
# Per-file loop so one bad file doesn't take out the whole
|
||||
# audit.
|
||||
continue-on-error: true
|
||||
env:
|
||||
PIP_CONSTRAINT: ${{ github.workspace }}/audit-reqs/build-constraints.txt
|
||||
run: |
|
||||
set +e
|
||||
cat > audit-reqs/build-constraints.txt <<'CONSTRAINTS'
|
||||
setuptools<78
|
||||
wheel
|
||||
CONSTRAINTS
|
||||
: > logs-pip-audit.txt
|
||||
for f in unsloth-deps studio extras extras-no-deps \
|
||||
no-torch-runtime overrides triton-kernels; do
|
||||
if ! grep -qE '^[^#[:space:]]' "audit-reqs/$f.txt"; then
|
||||
echo "[security-audit] $f.txt has no PyPI specs after git+ filter, skipping" \
|
||||
| tee -a logs-pip-audit.txt
|
||||
continue
|
||||
fi
|
||||
echo "::group::pip-audit -r audit-reqs/$f.txt"
|
||||
{
|
||||
echo
|
||||
echo "=== $f ==="
|
||||
pip-audit -r "audit-reqs/$f.txt" --format=columns
|
||||
echo "=== end $f (rc=$?) ==="
|
||||
} 2>&1 | tee -a logs-pip-audit.txt
|
||||
echo "::endgroup::"
|
||||
done
|
||||
{
|
||||
echo "## pip-audit (Python)"
|
||||
echo
|
||||
echo '### Coverage'
|
||||
echo '- unsloth core + `huggingfacenotorch` extras (pyproject.toml)'
|
||||
echo '- studio/backend/requirements/{studio,extras,extras-no-deps,no-torch-runtime,overrides,triton-kernels}.txt'
|
||||
echo '- `git+` specs are stripped before audit (out of scope: we audit PyPI archives)'
|
||||
echo
|
||||
echo '### Findings'
|
||||
echo '```'
|
||||
cat logs-pip-audit.txt
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# npm: Studio frontend
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
- name: npm audit (Studio frontend)
|
||||
# `npm audit` resolves the lockfile through the npmjs.com
|
||||
# advisory DB. `--audit-level=high` filters the noise floor
|
||||
# to only HIGH and CRITICAL. We do NOT pass --omit=dev: a
|
||||
# malicious dev-only dep can still steal secrets from a CI
|
||||
# runner, so dev deps need to be in the audit surface.
|
||||
continue-on-error: true
|
||||
working-directory: studio/frontend
|
||||
run: |
|
||||
set +e
|
||||
npm audit --audit-level=high | tee ../../logs-npm-audit.txt
|
||||
# Always also write the full JSON for grep-ability.
|
||||
npm audit --json > ../../logs-npm-audit.json || true
|
||||
{
|
||||
echo "## npm audit (Studio frontend)"
|
||||
echo
|
||||
echo '```'
|
||||
tail -200 ../../logs-npm-audit.txt
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# cargo: Studio Tauri shell
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
- name: cargo audit (Studio Tauri)
|
||||
# `--deny warnings` would make the job fail on any advisory.
|
||||
# Keep non-blocking initially; drop continue-on-error after
|
||||
# the baseline closes.
|
||||
continue-on-error: true
|
||||
working-directory: studio/src-tauri
|
||||
run: |
|
||||
set +e
|
||||
cargo audit | tee ../../logs-cargo-audit.txt
|
||||
{
|
||||
echo "## cargo audit (Studio Tauri)"
|
||||
echo
|
||||
echo '```'
|
||||
tail -200 ../../logs-cargo-audit.txt
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# OSV-Scanner: cross-ecosystem advisory DB (PyPI + npm + cargo)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
- name: OSV-Scanner (PyPI + npm + cargo, cross-ecosystem advisories)
|
||||
# OSV's advisory feed is a superset of GitHub-Advisory + RustSec
|
||||
# + npm advisories; running it alongside the per-ecosystem audit
|
||||
# tools catches CVEs that haven't propagated to the per-ecosystem
|
||||
# DBs yet (e.g. langchain-core CVE-2025-68664 was on OSV before
|
||||
# GitHub Advisory). Single binary, one transitive resolver, all
|
||||
# three lockfile types in one pass. Non-blocking until baselines
|
||||
# close.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set +e
|
||||
# OSV-Scanner ships a raw binary (no tarball) in v2.x.
|
||||
curl -fsSL -o /tmp/osv-scanner \
|
||||
https://github.com/google/osv-scanner/releases/download/v2.0.2/osv-scanner_linux_amd64
|
||||
chmod +x /tmp/osv-scanner
|
||||
/tmp/osv-scanner --version
|
||||
/tmp/osv-scanner scan source \
|
||||
--lockfile=studio/frontend/package-lock.json \
|
||||
--lockfile=studio/src-tauri/Cargo.lock \
|
||||
--lockfile=requirements.txt:audit-reqs/unsloth-deps.txt \
|
||||
--lockfile=requirements.txt:audit-reqs/studio.txt \
|
||||
--lockfile=requirements.txt:audit-reqs/no-torch-runtime.txt \
|
||||
--lockfile=requirements.txt:audit-reqs/overrides.txt \
|
||||
--lockfile=requirements.txt:audit-reqs/extras.txt \
|
||||
--lockfile=requirements.txt:audit-reqs/extras-no-deps.txt \
|
||||
--format=table 2>&1 | tee logs-osv-scanner.txt
|
||||
{
|
||||
echo "## OSV-Scanner (cross-ecosystem)"
|
||||
echo
|
||||
echo '```'
|
||||
tail -200 logs-osv-scanner.txt
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Semgrep: design-flaw detection (catches what regex-pattern
|
||||
# scanning of malicious authors cannot — first-party logic bugs
|
||||
# like langchain-core CVE-2025-68664 dumps/dumpd injection,
|
||||
# n8n CVE-2025-68668 _pyodide.eval_code sandbox escape, marimo
|
||||
# CVE-2026-39987 unauth WebSocket).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
- name: Semgrep (supply-chain + python rule packs)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set +e
|
||||
python -m pip install --quiet 'semgrep>=1.95'
|
||||
semgrep --version
|
||||
semgrep scan \
|
||||
--config p/supply-chain \
|
||||
--config p/python \
|
||||
--config p/javascript \
|
||||
--config p/security-audit \
|
||||
--severity ERROR --severity WARNING \
|
||||
--metrics off \
|
||||
--timeout 120 \
|
||||
studio/backend unsloth scripts \
|
||||
2>&1 | tee logs-semgrep.txt
|
||||
{
|
||||
echo "## Semgrep (supply-chain + python + javascript rules)"
|
||||
echo
|
||||
echo '```'
|
||||
tail -200 logs-semgrep.txt
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Lockfile pin verifier. The litellm 1.82.7 attack window was
|
||||
# ~40 minutes; anyone resolving with `>=` got the malicious
|
||||
# version automatically. Flag every spec in the requirements
|
||||
# files that does not pin to an exact `==` (or `@` for git
|
||||
# refs, or `===` for arbitrary equality). Warning-only for now;
|
||||
# graduate to blocking once the baseline is clean.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
- name: Lockfile pin verifier (Python requirements)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python <<'PY' | tee logs-pin-verifier.txt
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Specs that look like `pkg==1.2.3` or `pkg @ git+...` or
|
||||
# bare comments / -r lines are pinned-or-not-applicable.
|
||||
PINNED = re.compile(r"^\s*[A-Za-z0-9_.\-]+\s*(?:===|==)\s*[^,;]+\s*$")
|
||||
GIT_OR_URL = re.compile(r"^\s*[A-Za-z0-9_.\-]+\s*@\s*(?:git\+|https?://)")
|
||||
|
||||
unpinned = []
|
||||
for f in sorted(Path("studio/backend/requirements").glob("*.txt")):
|
||||
for i, raw in enumerate(f.read_text().splitlines(), 1):
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or line.startswith("-"):
|
||||
continue
|
||||
spec = line.split("#", 1)[0].strip().split(";", 1)[0].strip()
|
||||
if not spec:
|
||||
continue
|
||||
if "git+" in spec or PINNED.match(spec) or GIT_OR_URL.match(spec):
|
||||
continue
|
||||
unpinned.append((str(f), i, line))
|
||||
|
||||
print(f"::group::Lockfile pin status")
|
||||
if unpinned:
|
||||
print(f"WARN: {len(unpinned)} non-`==` specs across requirements/*.txt")
|
||||
print("(litellm 1.82.7 wave hit anyone on `>=`; tighten when feasible.)")
|
||||
for f, i, line in unpinned[:80]:
|
||||
print(f" {f}:{i}: {line}")
|
||||
if len(unpinned) > 80:
|
||||
print(f" ... and {len(unpinned) - 80} more")
|
||||
else:
|
||||
print("OK: every spec is exact-pinned.")
|
||||
print("::endgroup::")
|
||||
PY
|
||||
{
|
||||
echo "## Lockfile pin verifier"
|
||||
echo
|
||||
echo '```'
|
||||
cat logs-pin-verifier.txt
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Trivy is deliberately NOT installed here. Trivy was the entry
|
||||
# point for the litellm 1.82.7 supply-chain compromise (March
|
||||
# 2026): attackers force-rewrote 76 of 77 tags in
|
||||
# aquasecurity/trivy-action to point at malicious commits;
|
||||
# anyone running the action with a tag ref auto-pulled a
|
||||
# credential-harvesting payload. By design a security scanner
|
||||
# has broad read access to runner secrets, which is exactly
|
||||
# what made it the ideal pivot. We pick up Trivy's CVE coverage
|
||||
# from OSV-Scanner (NVD + GHSA + GitLab) and its secret
|
||||
# detection from TruffleHog. IaC misconfig detection (Trivy's
|
||||
# one unique value-add) is unfilled for now -- revisit with
|
||||
# checkov / kics when we ship a Dockerfile or k8s manifests.
|
||||
# See https://docs.litellm.ai/blog/security-update-march-2026
|
||||
# and the Microsoft / Trend Micro / Snyk incident write-ups.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# TruffleHog secret-leak scan on the PR diff. Catches API keys
|
||||
# / tokens / cred files committed accidentally. --only-verified
|
||||
# filters out probabilistic findings, so we only flag tokens
|
||||
# that the source provider confirmed are live. On push to main
|
||||
# / pip we scan the full repo; on PR we scan base..head.
|
||||
# SHA-pinned for the same reason as harden-runner above.
|
||||
# v3.95.2 commit:
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
- name: TruffleHog (secrets in diff)
|
||||
continue-on-error: true
|
||||
uses: trufflesecurity/trufflehog@17456f8c7d042d8c82c9a8ca9e937231f9f42e26 # v3.95.2
|
||||
with:
|
||||
path: ./
|
||||
base: ${{ github.event.pull_request.base.sha || '' }}
|
||||
head: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
# The action passes --no-update internally; passing it here
|
||||
# too triggers `flag 'no-update' cannot be repeated`. Stick
|
||||
# with --only-verified so we only flag tokens the source
|
||||
# provider confirmed are live (no probabilistic findings).
|
||||
extra_args: --only-verified
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# CycloneDX SBOM. Lets downstream consumers audit what's
|
||||
# actually shipped in unsloth wheels and the Studio backend
|
||||
# runtime. Generates one JSON file per requirements input plus
|
||||
# a combined SBOM keyed off pyproject.toml; uploads as a build
|
||||
# artifact (and a future step can attest it via SLSA).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
- name: Generate CycloneDX SBOM
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set +e
|
||||
python -m pip install --quiet 'cyclonedx-bom>=4.6'
|
||||
mkdir -p sbom
|
||||
# Per-requirements-file SBOM (the audit-reqs/ files are the
|
||||
# filtered, git+-stripped views built earlier in this job).
|
||||
# cyclonedx-py 4.x uses `--sv` for spec version and `-o` for
|
||||
# the output file; the older `--schema-version`/`--outfile`
|
||||
# spellings are not accepted.
|
||||
for f in audit-reqs/*.txt; do
|
||||
base=$(basename "$f" .txt)
|
||||
if grep -qE '^[^#[:space:]]' "$f"; then
|
||||
cyclonedx-py requirements "$f" \
|
||||
--sv 1.6 \
|
||||
--of JSON \
|
||||
-o "sbom/sbom-$base.json" 2>&1 | tail -5 || true
|
||||
fi
|
||||
done
|
||||
# Project-level SBOM from pyproject.toml.
|
||||
cyclonedx-py environment \
|
||||
--sv 1.6 \
|
||||
--of JSON \
|
||||
-o sbom/sbom-environment.json 2>&1 | tail -5 || true
|
||||
ls -la sbom/
|
||||
{
|
||||
echo "## CycloneDX SBOM"
|
||||
echo
|
||||
echo "Generated SBOM files:"
|
||||
ls sbom/ | sed 's/^/- sbom\//'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# GitHub Actions pinning verifier. tj-actions/changed-files
|
||||
# was compromised in March 2025; anyone using `@v4` (a mutable
|
||||
# ref) auto-shipped the malicious version. Catch every
|
||||
# non-SHA-pinned `uses:` across the workflows tree. Warn-only
|
||||
# initially so the existing baseline doesn't block PRs.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
- name: GitHub Actions pinning verifier
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python <<'PY' | tee logs-actions-pinning.txt
|
||||
import re
|
||||
from pathlib import Path
|
||||
# SHA pin = 40 hex chars after @
|
||||
SHA_PIN = re.compile(r"@[0-9a-f]{40}\b")
|
||||
# First-party / GitHub-published actions get a softer pass
|
||||
# (still recommended to pin; not a security gate).
|
||||
FIRST_PARTY = re.compile(r"^\s*-\s*uses:\s*(actions|github)/[^@]+@")
|
||||
USES = re.compile(r"^\s*-\s*uses:\s*([^@\s]+)@(\S+)")
|
||||
unpinned_third = []
|
||||
unpinned_first = []
|
||||
for f in sorted(Path(".github/workflows").glob("*.yml")):
|
||||
for i, line in enumerate(f.read_text().splitlines(), 1):
|
||||
m = USES.match(line)
|
||||
if not m:
|
||||
continue
|
||||
name, ref = m.group(1), m.group(2)
|
||||
if SHA_PIN.search(line):
|
||||
continue
|
||||
bucket = unpinned_first if FIRST_PARTY.match(line) else unpinned_third
|
||||
bucket.append((str(f), i, name, ref))
|
||||
print("::group::Action pinning status")
|
||||
print(f"third-party actions on mutable refs: {len(unpinned_third)}")
|
||||
for f, i, n, r in unpinned_third:
|
||||
print(f" HIGH {f}:{i}: {n}@{r}")
|
||||
print()
|
||||
print(f"first-party (actions/* | github/*) on mutable refs: {len(unpinned_first)}")
|
||||
for f, i, n, r in unpinned_first[:30]:
|
||||
print(f" WARN {f}:{i}: {n}@{r}")
|
||||
if len(unpinned_first) > 30:
|
||||
print(f" ... and {len(unpinned_first) - 30} more")
|
||||
print()
|
||||
print("Recommendation: pin third-party actions to a 40-char SHA.")
|
||||
print("Dependabot's github-actions ecosystem will auto-bump them.")
|
||||
print("::endgroup::")
|
||||
PY
|
||||
{
|
||||
echo "## GitHub Actions pinning verifier"
|
||||
echo
|
||||
echo '```'
|
||||
cat logs-actions-pinning.txt
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Hash-pin verifier. `==` pinning protects against version
|
||||
# drift but not against a re-uploaded malicious wheel at the
|
||||
# same version (PyPI lets a yanked release be re-published with
|
||||
# different bytes for ~5 minutes via `--filename` collision).
|
||||
# `pip install --require-hashes` rejects any download whose
|
||||
# SHA-256 doesn't match. Inspector step that reports how many
|
||||
# specs would gain from a hash pin -- conversion is a roadmap
|
||||
# item (needs pip-tools / uv pip compile --generate-hashes).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
- name: Hash-pin verifier (Python requirements)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python <<'PY' | tee logs-hash-verifier.txt
|
||||
import re
|
||||
from pathlib import Path
|
||||
PINNED = re.compile(r"^\s*[A-Za-z0-9_.\-]+\s*==\s*[^,;]+\s*$")
|
||||
HASH_LINE = re.compile(r"--hash=sha256:[0-9a-f]{64}")
|
||||
total_pinned = 0
|
||||
with_hash = 0
|
||||
for f in sorted(Path("studio/backend/requirements").glob("*.txt")):
|
||||
text = f.read_text()
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or line.startswith("-"):
|
||||
continue
|
||||
spec = line.split("#", 1)[0].strip().split(";", 1)[0]
|
||||
if PINNED.match(spec):
|
||||
total_pinned += 1
|
||||
if HASH_LINE.search(raw):
|
||||
with_hash += 1
|
||||
print(f"::group::Hash-pin status")
|
||||
print(f" exact == pins: {total_pinned}")
|
||||
print(f" with --hash=sha256: {with_hash}")
|
||||
print(f" without --hash: {total_pinned - with_hash}")
|
||||
print()
|
||||
print("Roadmap: convert to hash-locked installs via")
|
||||
print("`uv pip compile --generate-hashes` and `pip install --require-hashes`.")
|
||||
print("Hash-locked installs would have refused a republished")
|
||||
print("malicious litellm 1.82.7 wheel even at the same version.")
|
||||
print("::endgroup::")
|
||||
PY
|
||||
{
|
||||
echo "## Hash-pin verifier"
|
||||
echo
|
||||
echo '```'
|
||||
cat logs-hash-verifier.txt
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: advisory-audit-logs
|
||||
path: |
|
||||
logs-pip-audit.txt
|
||||
logs-npm-audit.txt
|
||||
logs-npm-audit.json
|
||||
logs-cargo-audit.txt
|
||||
logs-osv-scanner.txt
|
||||
logs-semgrep.txt
|
||||
logs-pin-verifier.txt
|
||||
logs-actions-pinning.txt
|
||||
logs-hash-verifier.txt
|
||||
audit-reqs/
|
||||
sbom/
|
||||
retention-days: 30
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Python: pre-install package scan (no install, no execution)
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
pip-scan-packages:
|
||||
# Downloads each declared dep WITHOUT installing it and inspects
|
||||
# the archive contents for known malicious patterns: weaponized
|
||||
# .pth files, credential stealers, obfuscated payloads,
|
||||
# install-time droppers, suspicious subprocess / network /
|
||||
# base64-blob combinations.
|
||||
#
|
||||
# This is the kind of check that would have caught:
|
||||
# - litellm 1.82.7 / 1.82.8 (March 2026, supply-chain compromise)
|
||||
# - the typo-squat campaign against PyTorch Lightning
|
||||
# before either landed in the install path. pip-audit only knows
|
||||
# about CVE-published vulnerabilities, so it does NOT see novel
|
||||
# malicious uploads. scan_packages.py runs deterministic regex
|
||||
# pattern matching, no LLM calls.
|
||||
#
|
||||
# `--with-deps` makes the scan transitive: every package the
|
||||
# declared set resolves to gets fetched and pattern-scanned, not
|
||||
# just the top-level pins. Resolving the full transitive closure
|
||||
# of the unsloth + Studio dep tree downloads several hundred
|
||||
# archives, hence the longer timeout.
|
||||
#
|
||||
# Sharded across runners for wall-clock parallelism. Each shard
|
||||
# runs scan_packages.py once with --with-deps so its own slice
|
||||
# benefits from pip's deduped transitive resolve. Shard
|
||||
# composition tries to balance load:
|
||||
# - hf-stack: pyproject extras + no-torch-runtime
|
||||
# (~150 archives, transformers/peft/accelerate/...)
|
||||
# - studio: FastAPI/Studio backend + overrides + extras-no-deps
|
||||
# (~150 archives, smaller scientific stack)
|
||||
# - extras: the heavy openai-whisper / scikit-learn / librosa
|
||||
# stack (~250 archives, dominant cost)
|
||||
# triton-kernels.txt is git+-only, fully skipped.
|
||||
name: ${{ matrix.shard.name }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- name: 'pip scan-packages :: hf-stack'
|
||||
id: hf-stack
|
||||
files: 'unsloth-deps no-torch-runtime'
|
||||
- name: 'pip scan-packages :: studio'
|
||||
id: studio
|
||||
files: 'studio overrides extras-no-deps'
|
||||
- name: 'pip scan-packages :: extras'
|
||||
id: extras
|
||||
files: 'extras'
|
||||
steps:
|
||||
# Egress audit on every shard. Each shard pulls hundreds of
|
||||
# PyPI archives -- if a malicious wheel ever phones home from
|
||||
# within the scanner sandbox (it shouldn't; we never execute
|
||||
# the archive), harden-runner's audit log records the host.
|
||||
- name: Harden runner (egress audit)
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
disable-sudo: true
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install scan_packages.py runtime deps
|
||||
# scan_packages.py imports requests + packaging at runtime to
|
||||
# talk to PyPI's JSON API and to parse version specifiers. We
|
||||
# do not install the packages it scans -- those are downloaded
|
||||
# raw and inspected without ever touching `pip install`.
|
||||
run: python -m pip install --upgrade pip requests packaging
|
||||
|
||||
- name: Build filtered requirements set
|
||||
# Mirrors the advisory-audit job's input transform: pyproject.toml
|
||||
# extraction + git+ stripping. scan_packages.py downloads
|
||||
# PyPI archives without building, so it tolerates legacy
|
||||
# setup.py packages (no resolver dry-run); but `--with-deps`
|
||||
# delegates resolution to a single `pip download` call that
|
||||
# cannot satisfy `git+` specs without git operations, so we
|
||||
# strip them here too.
|
||||
run: |
|
||||
mkdir -p audit-reqs
|
||||
python <<'PY' > audit-reqs/unsloth-deps.txt
|
||||
import tomllib
|
||||
with open("pyproject.toml", "rb") as f:
|
||||
d = tomllib.load(f)
|
||||
core = d["project"]["dependencies"]
|
||||
extras = d["project"]["optional-dependencies"]["huggingfacenotorch"]
|
||||
print("# Auto-generated from pyproject.toml by security-audit.yml.")
|
||||
print("# core deps + huggingfacenotorch extras.")
|
||||
for spec in core + extras:
|
||||
print(spec)
|
||||
PY
|
||||
for f in studio.txt extras.txt extras-no-deps.txt \
|
||||
no-torch-runtime.txt overrides.txt triton-kernels.txt; do
|
||||
python <<PY > "audit-reqs/$f"
|
||||
src = "studio/backend/requirements/$f"
|
||||
with open(src) as fh:
|
||||
for line in fh:
|
||||
stripped = line.strip()
|
||||
before_comment = stripped.split("#", 1)[0]
|
||||
if "git+" in before_comment:
|
||||
print(f"# [security-audit] skipped git+ spec: {stripped}")
|
||||
continue
|
||||
print(line.rstrip("\n"))
|
||||
PY
|
||||
done
|
||||
|
||||
- name: Sanity-check scan_packages.py
|
||||
# The scanner lives at scripts/scan_packages.py in this repo
|
||||
# so we don't depend on a network fetch at job time.
|
||||
run: |
|
||||
test -f scripts/scan_packages.py
|
||||
head -3 scripts/scan_packages.py
|
||||
grep -q "Standalone pre-install package scanner" scripts/scan_packages.py
|
||||
|
||||
- name: Scan declared + transitive Python deps
|
||||
# scan_packages.py exits 1 on CRITICAL/HIGH findings, 0 on
|
||||
# clean. We swallow the exit because the baseline isn't
|
||||
# triaged yet; surface the findings in the workflow summary.
|
||||
# Drop continue-on-error after the first clean run on main.
|
||||
#
|
||||
# `--with-deps` walks PyPI metadata to enumerate every
|
||||
# transitive dep the declared set would install, then scans
|
||||
# them all. Without this flag, we'd only catch a malicious
|
||||
# *direct* dep -- and supply-chain attacks usually land
|
||||
# several hops down (litellm 1.82.7 was a dep of a dep for
|
||||
# most users).
|
||||
#
|
||||
# This step runs once per matrix shard. Within a shard, every
|
||||
# -r file is fed to a single `pip download` call so pip
|
||||
# intersects version constraints and yields a deduped
|
||||
# transitive set (no point fetching the same transformers
|
||||
# wheel five times). Across shards we accept some redundant
|
||||
# downloads in exchange for wall-clock parallelism.
|
||||
continue-on-error: true
|
||||
env:
|
||||
SHARD_FILES: ${{ matrix.shard.files }}
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p logs
|
||||
LOG="logs-scan-packages-${{ matrix.shard.id }}.txt"
|
||||
echo "::group::shard ${{ matrix.shard.id }} input files"
|
||||
REQ_ARGS=()
|
||||
for f in $SHARD_FILES; do
|
||||
if grep -qE '^[^#[:space:]]' "audit-reqs/$f.txt"; then
|
||||
echo " + audit-reqs/$f.txt"
|
||||
REQ_ARGS+=( -r "audit-reqs/$f.txt" )
|
||||
else
|
||||
echo " - audit-reqs/$f.txt (empty after git+ filter, skipping)"
|
||||
fi
|
||||
done
|
||||
echo "::endgroup::"
|
||||
if [ ${#REQ_ARGS[@]} -eq 0 ]; then
|
||||
echo "[security-audit] shard ${{ matrix.shard.id }}: no PyPI specs, nothing to scan" \
|
||||
| tee "$LOG"
|
||||
else
|
||||
python scripts/scan_packages.py --with-deps "${REQ_ARGS[@]}" \
|
||||
2>&1 | tee "$LOG"
|
||||
fi
|
||||
{
|
||||
echo "## scan_packages :: shard ${{ matrix.shard.id }}"
|
||||
echo
|
||||
echo "### Files in this shard"
|
||||
for f in $SHARD_FILES; do echo "- audit-reqs/$f.txt"; done
|
||||
echo
|
||||
echo '### Findings (tail)'
|
||||
echo '```'
|
||||
tail -200 "$LOG"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: scan-packages-log-${{ matrix.shard.id }}
|
||||
path: |
|
||||
logs-scan-packages-${{ matrix.shard.id }}.txt
|
||||
audit-reqs/
|
||||
retention-days: 30
|
||||
2
.github/workflows/stale.yml
vendored
|
|
@ -11,7 +11,7 @@ jobs:
|
|||
issues: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
# The message to post on stale issues.
|
||||
# This message will ping the issue author.
|
||||
|
|
|
|||
156
.github/workflows/studio-api-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Studio API & Auth Tests -- HTTP-level integration tests for the
|
||||
# FastAPI surface. No Playwright, no model UI; tests/studio/test_studio_api_smoke.py
|
||||
# runs ~30 s and asserts:
|
||||
# - CORS hardening (no wildcard + credentials, no bootstrap leak)
|
||||
# - /api/system + /api/system/hardware require auth
|
||||
# - Auth state machine + JWT expiry
|
||||
# - API key lifecycle E2E (create / list / use / delete / reject)
|
||||
# - Auth file-mode hardening (Linux only)
|
||||
# - Inference lifecycle (force reload, bogus variant, /v1/models, /v1/embeddings, /v1/responses)
|
||||
# - Endpoint-by-endpoint auth audit
|
||||
#
|
||||
# Reuses the GGUF cache key from studio-ui-smoke.yml so the model
|
||||
# download is one cache-hit on the second job.
|
||||
|
||||
name: Studio API CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/**'
|
||||
- 'unsloth/**'
|
||||
- 'unsloth_cli/**'
|
||||
- 'install.sh'
|
||||
- 'pyproject.toml'
|
||||
- 'tests/studio/**'
|
||||
- '.github/workflows/studio-api-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api-smoke:
|
||||
name: Studio API & Auth Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 12
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18893'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Linux deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev libssl-dev jq
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
|
||||
id: cache-hf
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: hf-cache
|
||||
# Same key as studio-ui-smoke.yml so the two jobs share a
|
||||
# single GGUF download across CI.
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
|
||||
|
||||
- name: Prime HF_HOME with the GGUF
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub hf_transfer
|
||||
mkdir -p hf-cache
|
||||
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
||||
hf download "$GGUF_REPO" "$GGUF_FILE"
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Install pyjwt for the JWT-expiry forge test
|
||||
run: pip install 'pyjwt>=2.6'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
|
||||
- name: Pass bootstrap password + rotated targets to the test
|
||||
# The test does its own bootstrap-login + rotation to exercise
|
||||
# the auth state machine; we just pre-mint two random rotated
|
||||
# passwords for it. Mask them so the log is clean.
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
NEW2="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "::add-mask::$NEW2"
|
||||
echo "STUDIO_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run Studio API & Auth tests
|
||||
# The script is named WITHOUT a `test_` prefix so it isn't
|
||||
# auto-collected by pytest in Backend CI's `tests/` walk
|
||||
# (which doesn't set BASE_URL and would crash at import).
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18893
|
||||
STUDIO_AUTH_DIR: /home/runner/.unsloth/studio/auth
|
||||
run: python tests/studio/studio_api_smoke.py
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Upload API smoke logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: studio-api-smoke-log
|
||||
path: |
|
||||
logs/install.log
|
||||
logs/studio.log
|
||||
retention-days: 7
|
||||
105
.github/workflows/studio-backend-ci.yml
vendored
|
|
@ -12,7 +12,14 @@
|
|||
# - -k 'not llama_cpp_load_progress_live': spawns a real llama.cpp process,
|
||||
# not appropriate for CPU-only runners.
|
||||
#
|
||||
# ruff is non-blocking initially; remove `|| true` once the backend lints clean.
|
||||
# Two jobs:
|
||||
# - pytest matrix (3.10/3.11/3.12/3.13) over studio/backend/tests
|
||||
# - repo-cpu-tests: auto-discovered tests/ + state-isolated spoof files
|
||||
#
|
||||
# Whole-repo Python lint (syntax + ruff + debugger-leftover scan)
|
||||
# moved to the dedicated `Lint CI` workflow (.github/workflows/lint-ci.yml)
|
||||
# so it fires on every PR rather than only on studio/unsloth/tests
|
||||
# path changes.
|
||||
|
||||
name: Backend CI
|
||||
|
||||
|
|
@ -32,6 +39,9 @@ concurrency:
|
|||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
name: (Python ${{ matrix.python }})
|
||||
|
|
@ -42,9 +52,9 @@ jobs:
|
|||
matrix:
|
||||
python: ['3.10', '3.11', '3.12', '3.13']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '${{ matrix.python }}'
|
||||
cache: 'pip'
|
||||
|
|
@ -86,22 +96,34 @@ jobs:
|
|||
repo-cpu-tests:
|
||||
# Auto-discover everything under tests/ that is not GPU-bound by
|
||||
# design. New tests added in covered directories are picked up
|
||||
# without a workflow edit. Locally validated: 779 passed, 11
|
||||
# skipped, 23 deselected. tests/conftest.py (mirroring unsloth-zoo
|
||||
# PR #624) pre-loads unsloth_zoo.device_type and unsloth.device_type
|
||||
# under a mocked torch.cuda.is_available so the unsloth import
|
||||
# chain succeeds on CPU.
|
||||
# without a workflow edit. Locally validated: 760 passed, 1 skipped,
|
||||
# 23 deselected. tests/conftest.py (mirroring unsloth-zoo PR #624)
|
||||
# pre-loads unsloth_zoo.device_type and unsloth.device_type under a
|
||||
# mocked torch.cuda.is_available so the unsloth import chain
|
||||
# succeeds on CPU.
|
||||
name: Repo tests (CPU)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
# node + uv unlock ~60 tests that previously skipped on CI:
|
||||
# - 9 tests in test_chat_preset_builtin_invariants.py need node to
|
||||
# compile a tiny TS harness against the frontend chat sources.
|
||||
# - tests/python/* spawn fresh `uv venv`s to verify the no-torch
|
||||
# install path; they self-skip when uv is missing.
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install uv (for tests/python/* sandboxed venvs)
|
||||
run: pip install uv
|
||||
|
||||
- name: Install deps (shared shape with backend pytest job)
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
|
|
@ -110,19 +132,16 @@ jobs:
|
|||
python-multipart aiofiles sqlalchemy cryptography \
|
||||
pyyaml jinja2 mammoth unpdf requests typer \
|
||||
'numpy<3' pytest pytest-asyncio httpx
|
||||
# torchvision is needed because unsloth_zoo.vision_utils imports
|
||||
# it at module scope and is reached via unsloth.models._utils.
|
||||
# torchvision: unsloth_zoo.vision_utils imports it at module scope.
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu \
|
||||
'torch>=2.4,<2.11' 'torchvision<0.26'
|
||||
pip install 'transformers>=4.51,<5.5'
|
||||
# bitsandbytes is a hard import in unsloth/models/_utils.py.
|
||||
# Recent versions ship a CPU build so it installs on a free
|
||||
# Linux runner; the kernels still raise on use, but import
|
||||
# succeeds and the package collects.
|
||||
# bitsandbytes: hard import in unsloth/models/_utils.py. Recent
|
||||
# versions ship a CPU build that imports cleanly on Linux.
|
||||
pip install 'bitsandbytes>=0.45'
|
||||
# unsloth.device_type imports unsloth_zoo.utils.Version at module
|
||||
# scope, so the conftest harness needs unsloth_zoo on the path
|
||||
# even though it is an optional dep of unsloth.
|
||||
# scope, so the conftest preload needs unsloth_zoo even though
|
||||
# it is an optional dep of unsloth.
|
||||
pip install 'unsloth_zoo>=2026.5.1'
|
||||
pip install -e . --no-deps
|
||||
|
||||
|
|
@ -133,17 +152,24 @@ jobs:
|
|||
# Skip lazy compilation work the unsloth import chain wants to
|
||||
# do at import time on a real GPU.
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
# --ignore: GPU-bound directories (qlora and saving need real
|
||||
# weights / GPU; tests/sh is a shell suite the next step
|
||||
# handles; tests/utils is a helpers folder, not tests).
|
||||
# State-sensitive hardware-spoofing files are pulled out and run
|
||||
# in isolation in the next step because they mutate
|
||||
# hardware.py module globals (IS_ROCM / DEVICE) and pollute
|
||||
# downstream tests.
|
||||
# -m: honour markers already declared in tests/python/conftest.py
|
||||
# (`server` = needs studio venv, `e2e` = needs network).
|
||||
# --deselect: two registry tests that hit huggingface_hub for
|
||||
# live model existence checks; they belong on a network job.
|
||||
# --ignore: GPU-bound directories (qlora/saving need real weights;
|
||||
# tests/sh is the shell suite the next step handles; tests/utils
|
||||
# is a helpers folder); tests/vllm_compat + tests/version_compat
|
||||
# are dedicated multi-version drift canaries with their own job
|
||||
# in version-compat-ci.yml that installs the heavier dep set
|
||||
# (torchcodec, full transformers/peft/bnb pins) those tests need.
|
||||
# State-sensitive hardware-spoofing files run in isolation in the
|
||||
# next step because they mutate hardware.py module globals.
|
||||
# -m: honour markers from tests/python/conftest.py (`server` =
|
||||
# needs studio venv, `e2e` = needs network).
|
||||
# --deselect:
|
||||
# - test_model_registration / test_all_model_registration:
|
||||
# hit huggingface_hub for live model existence checks.
|
||||
# - test_autoconfig_works_with_no_torch_runtime / test_autoconfig_succeeds:
|
||||
# fail because no-torch-runtime.txt does not pin tokenizers
|
||||
# and the latest tokenizers (0.23.1) is incompatible with the
|
||||
# transformers it resolves to. Tracked separately; this is a
|
||||
# real bug in the no-torch install path, not a CI issue.
|
||||
run: |
|
||||
python -m pytest tests/ -q --tb=short \
|
||||
--ignore=tests/qlora \
|
||||
|
|
@ -152,9 +178,13 @@ jobs:
|
|||
--ignore=tests/sh \
|
||||
--ignore=tests/studio/test_hardware_dispatch_matrix.py \
|
||||
--ignore=tests/studio/test_is_mlx_dispatch_gate.py \
|
||||
--ignore=tests/vllm_compat \
|
||||
--ignore=tests/version_compat \
|
||||
-m 'not server and not e2e' \
|
||||
--deselect tests/test_model_registry.py::test_model_registration \
|
||||
--deselect tests/test_model_registry.py::test_all_model_registration
|
||||
--deselect tests/test_model_registry.py::test_all_model_registration \
|
||||
--deselect 'tests/python/test_tokenizers_and_torch_constraint.py::TestE2ETokenizersFix::test_autoconfig_works_with_no_torch_runtime' \
|
||||
--deselect 'tests/python/test_tokenizers_and_torch_constraint.py::TestE2EFullNoTorchSandbox::test_autoconfig_succeeds'
|
||||
|
||||
- name: Hardware-spoof tests (state-sensitive, run in isolation)
|
||||
env:
|
||||
|
|
@ -185,16 +215,3 @@ jobs:
|
|||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
ruff:
|
||||
name: Backend ruff lint (non-blocking)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- run: pip install ruff
|
||||
- name: ruff check (non-blocking until accumulated drift is cleared)
|
||||
run: ruff check studio/backend || true
|
||||
|
|
|
|||
17
.github/workflows/studio-frontend-ci.yml
vendored
|
|
@ -23,6 +23,9 @@ concurrency:
|
|||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Frontend build + bundle sanity
|
||||
|
|
@ -32,7 +35,7 @@ jobs:
|
|||
run:
|
||||
working-directory: studio/frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# FIXME: drop this step once @assistant-ui/* and assistant-stream
|
||||
# leave 0.x -- on 1.x, caret ranges are conventional. Until then,
|
||||
|
|
@ -49,7 +52,7 @@ jobs:
|
|||
fi
|
||||
echo "All assistant-ui packages are pinned exactly."
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
|
@ -99,9 +102,13 @@ jobs:
|
|||
continue-on-error: true
|
||||
run: npm run biome:check
|
||||
|
||||
- name: Upload built dist on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Upload built dist
|
||||
# Always upload so a green run is reviewable too -- the dist
|
||||
# output catches "tests passed but bundle changed unexpectedly"
|
||||
# regressions that would be invisible if we only kept artifacts
|
||||
# on failure.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: studio-frontend-dist
|
||||
path: studio/frontend/dist
|
||||
|
|
|
|||
835
.github/workflows/studio-inference-smoke.yml
vendored
|
|
@ -1,14 +1,31 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# End-to-end smoke: install Studio via install.sh --local --no-torch, download
|
||||
# a tiny GGUF, boot Studio, log in, change password, load the model, send a
|
||||
# chat completion, assert a non-empty response. Only workflow that tests "the
|
||||
# app actually works".
|
||||
# Three end-to-end smoke jobs that boot a freshly-installed Studio and
|
||||
# exercise the surfaces real users hit through the OpenAI / Anthropic
|
||||
# SDKs and curl. Each job picks the smallest model that exercises the
|
||||
# behaviour under test, primes HF_HOME via actions/cache, and shares
|
||||
# the install.sh --local --no-torch bootstrap.
|
||||
#
|
||||
# Model: Qwen3.5-2B UD-IQ3_XXS (~890 MiB) -- small enough that the cache miss
|
||||
# is cheap and inference fits in the 25 min CPU-runner budget. GGUF is cached
|
||||
# across runs via actions/cache.
|
||||
# 1. OpenAI, Anthropic API tests
|
||||
# gemma-3-270m-it UD-Q4_K_XL (~254 MiB).
|
||||
# Password rotation via /api/auth/change-password (old fails,
|
||||
# new works), then OpenAI + Anthropic Python SDKs against /v1/*
|
||||
# with temperature=0 and a fixed seed. Asserts the four-turn
|
||||
# conversation is deterministic across two runs.
|
||||
#
|
||||
# 2. Tool calling Tests
|
||||
# Qwen3.5-2B UD-IQ3_XXS (~890 MiB). OpenAI function calling,
|
||||
# server-side tools (python, terminal, web_search) via
|
||||
# enable_tools / enabled_tools, and enable_thinking on/off.
|
||||
#
|
||||
# 3. JSON, images
|
||||
# gemma-4-E2B-it UD-IQ3_XXS (~2.4 GiB) + mmproj-F16 (~986 MiB).
|
||||
# response_format JSON-schema decoding and OpenAI image_url
|
||||
# (data URI) plus Anthropic source/base64 image inputs.
|
||||
#
|
||||
# All three jobs run in parallel. Total wall time is dominated by job 3
|
||||
# on a cold cache; warm cache cuts that to ~3 min.
|
||||
|
||||
name: Studio GGUF CI
|
||||
|
||||
|
|
@ -23,7 +40,7 @@ on:
|
|||
- '.github/workflows/studio-inference-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
# Manual trigger for pre-warming the GGUF cache on main, or re-running
|
||||
# Manual trigger for pre-warming HF_HOME caches on main, or re-running
|
||||
# against an arbitrary branch without pushing a no-op commit.
|
||||
workflow_dispatch:
|
||||
|
||||
|
|
@ -31,76 +48,70 @@ concurrency:
|
|||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GGUF_REPO: unsloth/Qwen3.5-2B-GGUF
|
||||
GGUF_FILE: Qwen3.5-2B-UD-IQ3_XXS.gguf
|
||||
STUDIO_PORT: '18888'
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
inference:
|
||||
name: Studio boots, loads a GGUF, answers a chat completion
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Job 1: OpenAI, Anthropic API tests
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
openai-anthropic:
|
||||
name: OpenAI, Anthropic API tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18888'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Linux dependencies for llama.cpp prebuilt
|
||||
- name: Linux deps for llama.cpp prebuilt
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev libssl-dev jq
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Cache GGUF model file
|
||||
id: cache-gguf
|
||||
uses: actions/cache@v4
|
||||
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
|
||||
id: cache-hf
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: gguf-cache
|
||||
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
|
||||
|
||||
- name: Download GGUF if cache miss
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true'
|
||||
- name: Prime HF_HOME with the GGUF
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
# huggingface-cli was deprecated in huggingface_hub 1.13; the new CLI is `hf`.
|
||||
python -m pip install --upgrade huggingface_hub hf_transfer
|
||||
mkdir -p gguf-cache
|
||||
mkdir -p hf-cache
|
||||
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
||||
hf download "$GGUF_REPO" "$GGUF_FILE" --local-dir gguf-cache
|
||||
hf download "$GGUF_REPO" "$GGUF_FILE"
|
||||
|
||||
- name: Install Studio (--local, --no-torch keeps the install lean)
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Assert llama.cpp prebuilt was installed (no source-build fallback)
|
||||
# ubuntu-latest is CPU-only x86_64, so studio/setup.sh should route
|
||||
# to ggml-org/llama.cpp and grab bin-ubuntu-x64.tar.gz. A source
|
||||
# build here means the routing regressed.
|
||||
run: |
|
||||
if grep -q "falling back to source build" logs/install.log; then
|
||||
echo "::error::llama.cpp prebuilt path failed on ubuntu-latest. studio/setup.sh routing regressed; CPU-only Linux x86_64 should hit ggml-org/llama.cpp's bin-ubuntu-x64.tar.gz."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qE "prebuilt installed and validated|prebuilt up to date and validated" logs/install.log; then
|
||||
echo "::error::install.log does not contain the success marker for the llama.cpp prebuilt path. Did setup.sh skip the prebuilt install?"
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
echo "llama.cpp prebuilt path used successfully"
|
||||
- name: Install OpenAI + Anthropic Python SDKs
|
||||
run: pip install 'openai>=1.50' 'anthropic>=0.40'
|
||||
|
||||
- name: Reset auth + start Studio in the background
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
|
|
@ -110,75 +121,737 @@ jobs:
|
|||
|
||||
- name: Wait for /api/health
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
echo "ready after ${i}s"
|
||||
cat /tmp/health.json
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Studio did not become healthy in 60s"
|
||||
echo "Studio did not become healthy in 180s"
|
||||
tail -200 logs/studio.log
|
||||
exit 1
|
||||
|
||||
- name: Login + change bootstrap password
|
||||
- name: Password rotation (old must fail, new must work)
|
||||
run: |
|
||||
PW=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CIPasswordSmoke12345!"
|
||||
TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CIRotated-$(python -c 'import secrets; print(secrets.token_urlsafe(12))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
# 1. Login with the bootstrap password.
|
||||
OLD_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$PW\"}" | jq -r .access_token)
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}" | jq -r .access_token)
|
||||
[ -n "$OLD_TOKEN" ] && [ "$OLD_TOKEN" != "null" ] || { echo "bootstrap login failed"; exit 1; }
|
||||
# 2. Rotate to a fresh random password.
|
||||
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
|
||||
-d "{\"current_password\":\"$PW\",\"new_password\":\"$NEW\"}" > /dev/null
|
||||
# Re-login to clear must_change_password flag.
|
||||
-H "Authorization: Bearer $OLD_TOKEN" -H 'content-type: application/json' \
|
||||
-d "{\"current_password\":\"$OLD\",\"new_password\":\"$NEW\"}" > /dev/null
|
||||
# 3. Old password must now be rejected (HTTP 401).
|
||||
OLD_STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}")
|
||||
if [ "$OLD_STATUS" != "401" ]; then
|
||||
echo "::error::Login with old password returned $OLD_STATUS, expected 401"
|
||||
exit 1
|
||||
fi
|
||||
# 4. New password must succeed; capture the JWT for downstream steps.
|
||||
NEW_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
|
||||
[ -n "$NEW_TOKEN" ] && [ "$NEW_TOKEN" != "null" ] || { echo "new login failed"; exit 1; }
|
||||
echo "TOKEN=$NEW_TOKEN" >> "$GITHUB_ENV"
|
||||
echo "password rotation OK (old=401, new=200)"
|
||||
|
||||
- name: Load the GGUF into Studio
|
||||
- name: Load the GGUF (HF repo + variant, served from HF_HOME cache)
|
||||
run: |
|
||||
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
|
||||
--max-time 600 \
|
||||
-d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}" \
|
||||
| jq '{status, display_name, is_gguf, context_length}'
|
||||
|
||||
- name: Multi-turn determinism via OpenAI + Anthropic SDKs
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18888
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from openai import OpenAI
|
||||
from anthropic import Anthropic
|
||||
|
||||
BASE = os.environ["BASE_URL"]
|
||||
KEY = os.environ["TOKEN"] # JWT also accepted as Bearer on /v1/*
|
||||
SEED = 3407
|
||||
|
||||
# Four-turn conversation: the second and fourth turns can only be
|
||||
# answered correctly if the model sees the prior turns, so this
|
||||
# also exercises the conversation-history wiring.
|
||||
PROMPTS = [
|
||||
"What is 1+1?",
|
||||
"What did I ask before?",
|
||||
"What is the capital of France?",
|
||||
"Repeat the city name",
|
||||
]
|
||||
|
||||
def run_openai():
|
||||
client = OpenAI(base_url = f"{BASE}/v1", api_key = KEY)
|
||||
history, replies = [], []
|
||||
for prompt in PROMPTS:
|
||||
history.append({"role": "user", "content": prompt})
|
||||
resp = client.chat.completions.create(
|
||||
model = "default",
|
||||
messages = history,
|
||||
temperature = 0.0,
|
||||
max_tokens = 80,
|
||||
seed = SEED,
|
||||
extra_body = {"enable_thinking": False},
|
||||
)
|
||||
text = resp.choices[0].message.content or ""
|
||||
replies.append(text)
|
||||
history.append({"role": "assistant", "content": text})
|
||||
return replies
|
||||
|
||||
def run_anthropic():
|
||||
# Two SDK quirks vs. Studio:
|
||||
# 1. base_url must NOT include /v1 -- the SDK appends
|
||||
# /v1/messages itself; otherwise the request hits
|
||||
# /v1/v1/messages and 405s.
|
||||
# 2. The SDK sends `x-api-key` by default, but Studio's
|
||||
# auth layer is HTTPBearer-only. Override via
|
||||
# default_headers so Authorization: Bearer ... is
|
||||
# sent instead.
|
||||
client = Anthropic(
|
||||
base_url = BASE,
|
||||
api_key = "unused",
|
||||
default_headers = {"Authorization": f"Bearer {KEY}"},
|
||||
)
|
||||
history, replies = [], []
|
||||
for prompt in PROMPTS:
|
||||
history.append({"role": "user", "content": prompt})
|
||||
msg = client.messages.create(
|
||||
model = "default",
|
||||
max_tokens = 80,
|
||||
messages = history,
|
||||
temperature = 0.0,
|
||||
extra_body = {"seed": SEED, "enable_thinking": False},
|
||||
)
|
||||
text = "".join(b.text for b in msg.content if getattr(b, "type", None) == "text")
|
||||
replies.append(text)
|
||||
history.append({"role": "assistant", "content": text})
|
||||
return replies
|
||||
|
||||
for label, runner in (("openai", run_openai), ("anthropic", run_anthropic)):
|
||||
first = runner()
|
||||
second = runner()
|
||||
for i, (a, b) in enumerate(zip(first, second), start = 1):
|
||||
print(f"[{label} turn {i}] {a!r}")
|
||||
assert a, f"{label}: empty turn {i} response"
|
||||
assert a == b, (
|
||||
f"{label} non-deterministic at turn {i} with temperature=0.0:\n"
|
||||
f" run1: {a!r}\n run2: {b!r}"
|
||||
)
|
||||
# Sanity: turn-2 reply should mention the earlier question, and
|
||||
# turn-4 reply should mention Paris (model echoes the city it
|
||||
# produced for turn 3). Lower-cased substring checks keep the
|
||||
# assertion robust to formatting jitter.
|
||||
joined = " ".join(first).lower()
|
||||
assert "1" in first[0], f"{label}: turn-1 answer should contain '1', got {first[0]!r}"
|
||||
assert "paris" in joined, f"{label}: expected 'paris' somewhere in the four-turn transcript: {first}"
|
||||
print(f"[{label}] OK -- 4 turns, run1 == run2, history grounded")
|
||||
PY
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
ss -tln | grep ":${STUDIO_PORT}" || true
|
||||
|
||||
- name: Upload logs
|
||||
# Always upload so green runs are still reviewable.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: openai-anthropic-log
|
||||
path: |
|
||||
logs/studio.log
|
||||
logs/install.log
|
||||
retention-days: 7
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Job 2: Tool calling Tests
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
tool-calling:
|
||||
name: Tool calling Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
# Tool calling is the highest-volume GGUF in this workflow
|
||||
# (Qwen3.5-2B at IQ3_XXS = ~890 MiB). Caching HF_HOME would
|
||||
# store xet chunks + blobs + snapshots = ~4 GiB compressed --
|
||||
# 4-5x file-size inflation, dominated by xet chunks. Use main's
|
||||
# `--local-dir gguf-cache` pattern to cache the flat .gguf only.
|
||||
# Studio's /api/inference/load accepts either a HF repo (which
|
||||
# uses HF_HOME) or an absolute file path; passing the absolute
|
||||
# path keeps the test off HF_HOME entirely so the cache size
|
||||
# tracks the GGUF file 1:1. The OpenAI/Anth and JSON+images
|
||||
# jobs still cover the gguf_variant resolution path.
|
||||
GGUF_REPO: unsloth/Qwen3.5-2B-GGUF
|
||||
GGUF_FILE: Qwen3.5-2B-UD-IQ3_XXS.gguf
|
||||
STUDIO_PORT: '18889'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Linux deps for llama.cpp prebuilt
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev libssl-dev jq
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Cache GGUF model file
|
||||
id: cache-gguf
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: gguf-cache
|
||||
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
||||
|
||||
- name: Download GGUF if cache miss
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub hf_transfer
|
||||
mkdir -p gguf-cache
|
||||
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
||||
hf download "$GGUF_REPO" "$GGUF_FILE" --local-dir gguf-cache
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Reset auth + boot Studio (API-only, default tool policy)
|
||||
# We deliberately use the API-only mode rather than
|
||||
# `unsloth studio run` because the latter calls
|
||||
# `set_tool_policy(...)` with a resolved bool: on loopback the
|
||||
# default resolves to True, which forces every request through
|
||||
# the server-side agentic loop and breaks the standard
|
||||
# function-calling test below. API-only mode leaves
|
||||
# tool_policy=None so each request's `enable_tools` field is
|
||||
# honoured.
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health, log in, change password, load model
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CITool-$(python -c 'import secrets; print(secrets.token_urlsafe(12))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
OLD_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}" | jq -r .access_token)
|
||||
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \
|
||||
-H "Authorization: Bearer $OLD_TOKEN" -H 'content-type: application/json' \
|
||||
-d "{\"current_password\":\"$OLD\",\"new_password\":\"$NEW\"}" > /dev/null
|
||||
TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
|
||||
echo "API_KEY=$TOKEN" >> "$GITHUB_ENV"
|
||||
GGUF_PATH="$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}"
|
||||
ls -lh "$GGUF_PATH"
|
||||
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
|
||||
--max-time 600 \
|
||||
-d "{\"model_path\":\"$GGUF_PATH\",\"is_lora\":false,\"max_seq_length\":2048}" \
|
||||
| jq '{status, display_name, is_gguf, context_length}'
|
||||
| jq '{status, display_name}'
|
||||
|
||||
- name: Send a chat completion + assert non-empty response
|
||||
- name: Tool calling, server-side tools, thinking on/off
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18889
|
||||
run: |
|
||||
RESP=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/chat/completions" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
|
||||
--max-time 900 \
|
||||
-d '{
|
||||
"messages":[{"role":"user","content":"Say hello in one short sentence."}],
|
||||
"max_tokens":40,
|
||||
"stream":false
|
||||
}')
|
||||
echo "raw response: $RESP"
|
||||
CONTENT=$(echo "$RESP" | jq -r '.choices[0].message.content // empty')
|
||||
echo "model response: $CONTENT"
|
||||
if [ -z "$CONTENT" ]; then
|
||||
echo "::error::Empty assistant response from Studio"
|
||||
exit 1
|
||||
fi
|
||||
python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
BASE = os.environ["BASE_URL"]
|
||||
KEY = os.environ["API_KEY"]
|
||||
SEED = 3407
|
||||
|
||||
def post(path, body, *, timeout = 240):
|
||||
"""Plain JSON POST. For requests that don't go through
|
||||
the server-side agentic loop, the response is one JSON
|
||||
object."""
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}{path}",
|
||||
data = data,
|
||||
method = "POST",
|
||||
headers = {
|
||||
"Authorization": f"Bearer {KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout = timeout) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
|
||||
def post_sse(path, body, *, timeout = 600):
|
||||
"""POST a streaming request and accumulate the assistant
|
||||
text deltas. The server-side agentic loop ALWAYS returns
|
||||
SSE regardless of the request's `stream` field, so any
|
||||
call with enable_tools=true must use this helper."""
|
||||
body = {**body, "stream": True}
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}{path}",
|
||||
data = data,
|
||||
method = "POST",
|
||||
headers = {
|
||||
"Authorization": f"Bearer {KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
parts = []
|
||||
with urllib.request.urlopen(req, timeout = timeout) as resp:
|
||||
for raw in resp:
|
||||
line = raw.decode().strip()
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
payload = line[6:]
|
||||
if payload == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
for choice in chunk.get("choices", []):
|
||||
delta = choice.get("delta", {}) or {}
|
||||
if delta.get("content"):
|
||||
parts.append(delta["content"])
|
||||
return "".join(parts)
|
||||
|
||||
# ── 1. Standard OpenAI function calling ──────────────────────
|
||||
weather_tool = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get current weather for a city.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
status, data = post("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "What is the weather in Paris?"}],
|
||||
"tools": [weather_tool],
|
||||
"tool_choice": "required",
|
||||
"stream": False,
|
||||
"temperature": 0.0,
|
||||
"seed": SEED,
|
||||
"max_tokens": 120,
|
||||
})
|
||||
assert status == 200, f"tool call status {status}: {data}"
|
||||
choice = data["choices"][0]
|
||||
assert choice["finish_reason"] == "tool_calls", f"finish_reason={choice['finish_reason']!r}"
|
||||
tc = choice["message"]["tool_calls"][0]
|
||||
assert tc["function"]["name"] == "get_weather"
|
||||
args = json.loads(tc["function"]["arguments"])
|
||||
assert args.get("city"), f"missing city arg: {args}"
|
||||
print(f"[tools] PASS function calling -> {tc['function']['name']}({args})")
|
||||
|
||||
# ── 2. Server-side python tool ───────────────────────────────
|
||||
# 123 * 456 = 56088. The agentic loop streams SSE; we
|
||||
# accumulate the assistant text and look for the answer. We
|
||||
# accept "56088" or "56,088" since the model may format it.
|
||||
content = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}],
|
||||
"enable_tools": True,
|
||||
"enabled_tools": ["python"],
|
||||
"session_id": "ci-tool-calling-py",
|
||||
"temperature": 0.0,
|
||||
"seed": SEED,
|
||||
"max_tokens": 600,
|
||||
})
|
||||
assert "56088" in content or "56,088" in content, (
|
||||
f"expected 56088 in python-tool answer, got: {content!r}"
|
||||
)
|
||||
print(f"[tools] PASS python tool ({len(content)} chars)")
|
||||
|
||||
# ── 3. Server-side bash (terminal) tool ──────────────────────
|
||||
content = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "Use the terminal tool to run `echo hello-bash-tool` and tell me the exact output."}],
|
||||
"enable_tools": True,
|
||||
"enabled_tools": ["terminal"],
|
||||
"session_id": "ci-tool-calling-bash",
|
||||
"temperature": 0.0,
|
||||
"seed": SEED,
|
||||
"max_tokens": 600,
|
||||
})
|
||||
assert "hello-bash-tool" in content, (
|
||||
f"expected 'hello-bash-tool' in terminal-tool answer, got: {content!r}"
|
||||
)
|
||||
print(f"[tools] PASS bash/terminal tool ({len(content)} chars)")
|
||||
|
||||
# ── 4. Server-side web_search tool ───────────────────────────
|
||||
# DuckDuckGo is flaky from CI runners and small Qwen3.5-2B
|
||||
# may not actually search. Only assert that the SSE stream
|
||||
# opens and yields any data; HTTP / parser failures already
|
||||
# raise above.
|
||||
try:
|
||||
content = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
|
||||
"enable_tools": True,
|
||||
"enabled_tools": ["web_search"],
|
||||
"session_id": "ci-tool-calling-web",
|
||||
"temperature": 0.0,
|
||||
"seed": SEED,
|
||||
"max_tokens": 400,
|
||||
})
|
||||
print(f"[tools] PASS web_search stream ({len(content)} chars)")
|
||||
except Exception as exc:
|
||||
print(f"[tools] WARN web_search probe failed (non-blocking): {exc}")
|
||||
|
||||
# ── 5. Thinking on / off ─────────────────────────────────────
|
||||
# Studio strips think blocks from message.content for tools-mode
|
||||
# responses, so we toggle plain chat (no enable_tools) and look
|
||||
# at the surfaced reasoning_content / message.thinking field.
|
||||
def thinking_call(enable):
|
||||
status, data = post("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "Briefly: is 17 prime?"}],
|
||||
"stream": False,
|
||||
"enable_thinking": enable,
|
||||
"temperature": 0.0,
|
||||
"seed": SEED,
|
||||
"max_tokens": 300,
|
||||
})
|
||||
assert status == 200
|
||||
msg = data["choices"][0]["message"]
|
||||
# Studio surfaces thinking via reasoning_content (OpenAI
|
||||
# extension). Fall back to inline <think> markers for
|
||||
# robustness across template versions.
|
||||
raw = (msg.get("content") or "") + (msg.get("reasoning_content") or "")
|
||||
return raw
|
||||
|
||||
on_text = thinking_call(True)
|
||||
off_text = thinking_call(False)
|
||||
had_think_on = ("<think>" in on_text) or len(on_text) > 80
|
||||
had_think_off = ("<think>" in off_text) and len(off_text) > 0
|
||||
assert had_think_on, (
|
||||
f"enable_thinking=True produced no thinking signal: {on_text!r}"
|
||||
)
|
||||
# Off-mode should not contain the literal <think> marker.
|
||||
assert "<think>" not in off_text, (
|
||||
f"enable_thinking=False but <think> still present: {off_text!r}"
|
||||
)
|
||||
print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)")
|
||||
PY
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" || true
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
ss -tln | grep ":${STUDIO_PORT}" || true
|
||||
|
||||
- name: Upload Studio + install logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Upload logs
|
||||
# Always upload so green runs are still reviewable.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: studio-inference-log
|
||||
name: tool-calling-log
|
||||
path: |
|
||||
logs/studio.log
|
||||
logs/install.log
|
||||
retention-days: 7
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Job 3: JSON, images
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
json-images:
|
||||
name: JSON, images
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-4-E2B-it-GGUF
|
||||
GGUF_VARIANT: UD-IQ3_XXS
|
||||
GGUF_FILE: gemma-4-E2B-it-UD-IQ3_XXS.gguf
|
||||
MMPROJ_FILE: mmproj-F16.gguf
|
||||
STUDIO_PORT: '18890'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Linux deps for llama.cpp prebuilt
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev libssl-dev jq
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Cache HF_HOME for ${{ env.GGUF_REPO }} (model + mmproj)
|
||||
id: cache-hf
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v1
|
||||
|
||||
- name: Prime HF_HOME with the GGUF + mmproj
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub hf_transfer
|
||||
mkdir -p hf-cache
|
||||
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
||||
hf download "$GGUF_REPO" "$GGUF_FILE"
|
||||
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
||||
hf download "$GGUF_REPO" "$MMPROJ_FILE"
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Install OpenAI + Anthropic Python SDKs
|
||||
run: pip install 'openai>=1.50' 'anthropic>=0.40'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
# See Job 2's comment: API-only mode keeps tool_policy=None so
|
||||
# response_format requests aren't routed through the agentic
|
||||
# tool loop.
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health, log in, change password, load model
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CIJson-$(python -c 'import secrets; print(secrets.token_urlsafe(12))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
OLD_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}" | jq -r .access_token)
|
||||
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \
|
||||
-H "Authorization: Bearer $OLD_TOKEN" -H 'content-type: application/json' \
|
||||
-d "{\"current_password\":\"$OLD\",\"new_password\":\"$NEW\"}" > /dev/null
|
||||
TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
|
||||
echo "API_KEY=$TOKEN" >> "$GITHUB_ENV"
|
||||
# Load the GGUF (mmproj is auto-detected via the HF repo
|
||||
# lookup, the cached file is pulled out of HF_HOME).
|
||||
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
|
||||
--max-time 900 \
|
||||
-d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}" \
|
||||
| jq '{status, display_name, is_vision}'
|
||||
|
||||
- name: JSON schema decoding + image input
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18890
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
from openai import OpenAI
|
||||
from anthropic import Anthropic
|
||||
|
||||
BASE = os.environ["BASE_URL"]
|
||||
KEY = os.environ["API_KEY"]
|
||||
SEED = 3407
|
||||
|
||||
def post(path, body, *, timeout = 240):
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}{path}",
|
||||
data = json.dumps(body).encode(),
|
||||
method = "POST",
|
||||
headers = {
|
||||
"Authorization": f"Bearer {KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout = timeout) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
|
||||
# ── 1. response_format = json_object (JSON mode) ─────────────
|
||||
# llama.cpp's HTTP server supports OpenAI-compatible JSON
|
||||
# mode: `response_format: {"type": "json_object"}` constrains
|
||||
# the model to emit syntactically-valid JSON. We use raw HTTP
|
||||
# rather than the OpenAI SDK so that the field shape Studio
|
||||
# forwards to llama-server is unambiguous (the SDK rewrites
|
||||
# response_format depending on which variant it recognises).
|
||||
# We deliberately do NOT pass a strict JSON schema -- on
|
||||
# small Gemma-4 quants the GBNF-from-schema path occasionally
|
||||
# produces empty output, and JSON mode is the surface we care
|
||||
# about exposing through Studio.
|
||||
status, data = post("/v1/chat/completions", {
|
||||
"model": "default",
|
||||
"messages": [
|
||||
{"role": "system", "content": 'Reply with a single JSON object of the form {"city": "...", "country": "..."}. Output ONLY the JSON, nothing else.'},
|
||||
{"role": "user", "content": "What is the capital of France?"},
|
||||
],
|
||||
"temperature": 0.0,
|
||||
"max_tokens": 200,
|
||||
"seed": SEED,
|
||||
"stream": False,
|
||||
"enable_thinking": False,
|
||||
"response_format": {"type": "json_object"},
|
||||
}, timeout = 600)
|
||||
assert status == 200, f"json status {status}: {data}"
|
||||
content = (data["choices"][0]["message"].get("content") or "").strip()
|
||||
# Some chat templates wrap JSON in ```json fences even in JSON
|
||||
# mode -- strip those before parsing.
|
||||
if content.startswith("```"):
|
||||
content = content.split("```", 2)[1]
|
||||
if content.startswith("json"):
|
||||
content = content[4:]
|
||||
content = content.strip("`\n ")
|
||||
parsed = json.loads(content)
|
||||
assert "paris" in str(parsed.get("city", "")).lower(), (
|
||||
f"city != Paris: {parsed}"
|
||||
)
|
||||
print(f"[json] PASS json_object -> {parsed}")
|
||||
|
||||
# ── 2. OpenAI image_url (data URI base64) ───────────────────
|
||||
# 64x64 solid-red PNG. stb_image (used by Studio's image
|
||||
# normaliser at routes/inference.py:3410) rejects 4x4 or
|
||||
# smaller PNGs as truncated, so we go up to 64x64 -- still
|
||||
# tiny in token cost. The assertion is loose: any non-empty
|
||||
# response from the vision path proves multimodal end-to-end
|
||||
# wiring; small VL quants are weak at colour identification.
|
||||
PNG_64X64_RED_B64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAYklEQVR4nO3PMQ0AIADAMEAI/k"
|
||||
"UhBhEcDcmqYJtn7/GzpQNeNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA"
|
||||
"1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaBdCJ0BmMJ25zMAAAAASUVORK5CYII="
|
||||
)
|
||||
data_uri = f"data:image/png;base64,{PNG_64X64_RED_B64}"
|
||||
|
||||
client = OpenAI(base_url = f"{BASE}/v1", api_key = KEY)
|
||||
openai_resp = client.chat.completions.create(
|
||||
model = "default",
|
||||
temperature = 0.0,
|
||||
max_tokens = 80,
|
||||
seed = SEED,
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": data_uri}},
|
||||
{"type": "text", "text": "What colour dominates this image? Reply in one word."},
|
||||
],
|
||||
}],
|
||||
)
|
||||
openai_text = (openai_resp.choices[0].message.content or "").lower()
|
||||
print(f"[image/openai] reply: {openai_text!r}")
|
||||
assert openai_text, "OpenAI image_url returned empty content"
|
||||
# We do not strictly require 'red' -- some quants of small VL
|
||||
# models are weak at colour names. Just require a non-empty
|
||||
# answer; the vision path is the part under test.
|
||||
print("[image/openai] PASS image_url accepted, non-empty response")
|
||||
|
||||
# ── 3. Anthropic source/base64 image ────────────────────────
|
||||
# Two SDK quirks vs. Studio: base_url must NOT include /v1
|
||||
# (the SDK appends it itself; otherwise /v1/v1/messages -> 405),
|
||||
# and Studio's auth is HTTPBearer-only so the SDK's default
|
||||
# x-api-key header is ignored -- send Authorization: Bearer
|
||||
# via default_headers.
|
||||
anthropic = Anthropic(
|
||||
base_url = BASE,
|
||||
api_key = "unused",
|
||||
default_headers = {"Authorization": f"Bearer {KEY}"},
|
||||
)
|
||||
a_msg = anthropic.messages.create(
|
||||
model = "default",
|
||||
max_tokens = 80,
|
||||
temperature = 0.0,
|
||||
extra_body = {"seed": SEED},
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": PNG_64X64_RED_B64,
|
||||
},
|
||||
},
|
||||
{"type": "text", "text": "Describe this image briefly."},
|
||||
],
|
||||
}],
|
||||
)
|
||||
a_text = "".join(b.text for b in a_msg.content if getattr(b, "type", None) == "text")
|
||||
print(f"[image/anthropic] reply: {a_text!r}")
|
||||
assert a_text, "Anthropic source/base64 returned empty content"
|
||||
print("[image/anthropic] PASS source/base64 accepted, non-empty response")
|
||||
PY
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
ss -tln | grep ":${STUDIO_PORT}" || true
|
||||
|
||||
- name: Upload logs
|
||||
# Always upload so green runs are still reviewable.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: json-images-log
|
||||
path: |
|
||||
logs/studio.log
|
||||
logs/install.log
|
||||
|
|
|
|||
143
.github/workflows/studio-mac-api-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Mac counterpart to studio-api-smoke.yml. Same tests/studio/
|
||||
# studio_api_smoke.py exercise (CORS hardening, auth state machine,
|
||||
# JWT expiry, API key lifecycle, /v1/models / /v1/embeddings /
|
||||
# /v1/responses, endpoint-by-endpoint auth audit) but on a real
|
||||
# Apple Silicon (macos-14, M1) runner. Drops the apt-get block;
|
||||
# GitHub-hosted macos-14 ships curl + jq.
|
||||
|
||||
name: Mac Studio API CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/**'
|
||||
- 'unsloth/**'
|
||||
- 'unsloth_cli/**'
|
||||
- 'install.sh'
|
||||
- 'pyproject.toml'
|
||||
- 'tests/studio/**'
|
||||
- '.github/workflows/studio-mac-api-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api-smoke:
|
||||
name: Studio API & Auth Tests
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18895'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
|
||||
id: cache-hf
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
|
||||
|
||||
- name: Prime HF_HOME with the GGUF
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub hf_transfer
|
||||
mkdir -p hf-cache
|
||||
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
||||
hf download "$GGUF_REPO" "$GGUF_FILE"
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Assert install.sh used the Mac llama.cpp prebuilt
|
||||
run: |
|
||||
if grep -q "falling back to source build" logs/install.log; then
|
||||
echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install pyjwt for the JWT-expiry forge test
|
||||
run: pip install 'pyjwt>=2.6'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
|
||||
- name: Pass bootstrap password + rotated targets to the test
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
NEW2="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "::add-mask::$NEW2"
|
||||
echo "STUDIO_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run Studio API & Auth tests
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18895
|
||||
STUDIO_AUTH_DIR: /Users/runner/.unsloth/studio/auth
|
||||
run: python tests/studio/studio_api_smoke.py
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Upload API smoke logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: mac-studio-api-smoke-log
|
||||
path: |
|
||||
logs/install.log
|
||||
logs/studio.log
|
||||
retention-days: 7
|
||||
979
.github/workflows/studio-mac-inference-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,979 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Three end-to-end smoke jobs that boot a freshly-installed Studio and
|
||||
# exercise the surfaces real users hit through the OpenAI / Anthropic
|
||||
# SDKs and curl. Each job picks the smallest model that exercises the
|
||||
# behaviour under test, primes HF_HOME via actions/cache, and shares
|
||||
# the install.sh --local --no-torch bootstrap.
|
||||
#
|
||||
# 1. OpenAI, Anthropic API tests
|
||||
# gemma-3-270m-it UD-Q4_K_XL (~254 MiB).
|
||||
# Password rotation via /api/auth/change-password (old fails,
|
||||
# new works), then OpenAI + Anthropic Python SDKs against /v1/*
|
||||
# with temperature=0 and a fixed seed. Asserts the four-turn
|
||||
# conversation is deterministic across two runs.
|
||||
#
|
||||
# 2. Tool calling Tests
|
||||
# Qwen3.5-2B UD-IQ3_XXS (~890 MiB). OpenAI function calling,
|
||||
# server-side tools (python, terminal, web_search) via
|
||||
# enable_tools / enabled_tools, and enable_thinking on/off.
|
||||
#
|
||||
# 3. JSON, images
|
||||
# gemma-4-E2B-it UD-IQ3_XXS (~2.4 GiB) + mmproj-F16 (~986 MiB).
|
||||
# response_format JSON-schema decoding and OpenAI image_url
|
||||
# (data URI) plus Anthropic source/base64 image inputs.
|
||||
#
|
||||
# All three jobs run in parallel. Total wall time is dominated by job 3
|
||||
# on a cold cache; warm cache cuts that to ~3 min.
|
||||
|
||||
name: Mac Studio GGUF CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/**'
|
||||
- 'unsloth/**'
|
||||
- 'unsloth_cli/**'
|
||||
- 'install.sh'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/studio-mac-inference-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
# Manual trigger for pre-warming HF_HOME caches on main, or re-running
|
||||
# against an arbitrary branch without pushing a no-op commit.
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Job 1: OpenAI, Anthropic API tests
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
openai-anthropic:
|
||||
name: OpenAI, Anthropic API tests
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18888'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
|
||||
id: cache-hf
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
|
||||
|
||||
- name: Prime HF_HOME with the GGUF
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub hf_transfer
|
||||
mkdir -p hf-cache
|
||||
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
||||
hf download "$GGUF_REPO" "$GGUF_FILE"
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Assert install.sh used the Mac llama.cpp prebuilt
|
||||
run: |
|
||||
if grep -q "falling back to source build" logs/install.log; then
|
||||
echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install OpenAI + Anthropic Python SDKs
|
||||
run: pip install 'openai>=1.50' 'anthropic>=0.40'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Studio did not become healthy in 180s"
|
||||
tail -200 logs/studio.log
|
||||
exit 1
|
||||
|
||||
- name: Password rotation (old must fail, new must work)
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CIRotated-$(python -c 'import secrets; print(secrets.token_urlsafe(12))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
# 1. Login with the bootstrap password.
|
||||
OLD_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}" | jq -r .access_token)
|
||||
[ -n "$OLD_TOKEN" ] && [ "$OLD_TOKEN" != "null" ] || { echo "bootstrap login failed"; exit 1; }
|
||||
# 2. Rotate to a fresh random password.
|
||||
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \
|
||||
-H "Authorization: Bearer $OLD_TOKEN" -H 'content-type: application/json' \
|
||||
-d "{\"current_password\":\"$OLD\",\"new_password\":\"$NEW\"}" > /dev/null
|
||||
# 3. Old password must now be rejected (HTTP 401).
|
||||
OLD_STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}")
|
||||
if [ "$OLD_STATUS" != "401" ]; then
|
||||
echo "::error::Login with old password returned $OLD_STATUS, expected 401"
|
||||
exit 1
|
||||
fi
|
||||
# 4. New password must succeed; capture the JWT for downstream steps.
|
||||
NEW_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
|
||||
[ -n "$NEW_TOKEN" ] && [ "$NEW_TOKEN" != "null" ] || { echo "new login failed"; exit 1; }
|
||||
echo "TOKEN=$NEW_TOKEN" >> "$GITHUB_ENV"
|
||||
echo "password rotation OK (old=401, new=200)"
|
||||
|
||||
- name: Load the GGUF (HF repo + variant, served from HF_HOME cache)
|
||||
run: |
|
||||
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
|
||||
--max-time 600 \
|
||||
-d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}" \
|
||||
| jq '{status, display_name, is_gguf, context_length}'
|
||||
|
||||
- name: Multi-turn determinism via OpenAI + Anthropic SDKs
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18888
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from openai import OpenAI
|
||||
from anthropic import Anthropic
|
||||
|
||||
BASE = os.environ["BASE_URL"]
|
||||
KEY = os.environ["TOKEN"] # JWT also accepted as Bearer on /v1/*
|
||||
SEED = 3407
|
||||
|
||||
# Four-turn conversation: the second and fourth turns can only be
|
||||
# answered correctly if the model sees the prior turns, so this
|
||||
# also exercises the conversation-history wiring.
|
||||
PROMPTS = [
|
||||
"What is 1+1?",
|
||||
"What did I ask before?",
|
||||
"What is the capital of France?",
|
||||
"Repeat the city name",
|
||||
]
|
||||
|
||||
def run_openai():
|
||||
client = OpenAI(base_url = f"{BASE}/v1", api_key = KEY)
|
||||
history, replies = [], []
|
||||
for prompt in PROMPTS:
|
||||
history.append({"role": "user", "content": prompt})
|
||||
resp = client.chat.completions.create(
|
||||
model = "default",
|
||||
messages = history,
|
||||
temperature = 0.0,
|
||||
max_tokens = 80,
|
||||
seed = SEED,
|
||||
extra_body = {"enable_thinking": False},
|
||||
)
|
||||
text = resp.choices[0].message.content or ""
|
||||
replies.append(text)
|
||||
history.append({"role": "assistant", "content": text})
|
||||
return replies
|
||||
|
||||
def run_anthropic():
|
||||
# Two SDK quirks vs. Studio:
|
||||
# 1. base_url must NOT include /v1 -- the SDK appends
|
||||
# /v1/messages itself; otherwise the request hits
|
||||
# /v1/v1/messages and 405s.
|
||||
# 2. The SDK sends `x-api-key` by default, but Studio's
|
||||
# auth layer is HTTPBearer-only. Override via
|
||||
# default_headers so Authorization: Bearer ... is
|
||||
# sent instead.
|
||||
client = Anthropic(
|
||||
base_url = BASE,
|
||||
api_key = "unused",
|
||||
default_headers = {"Authorization": f"Bearer {KEY}"},
|
||||
)
|
||||
history, replies = [], []
|
||||
for prompt in PROMPTS:
|
||||
history.append({"role": "user", "content": prompt})
|
||||
msg = client.messages.create(
|
||||
model = "default",
|
||||
max_tokens = 80,
|
||||
messages = history,
|
||||
temperature = 0.0,
|
||||
extra_body = {"seed": SEED, "enable_thinking": False},
|
||||
)
|
||||
text = "".join(b.text for b in msg.content if getattr(b, "type", None) == "text")
|
||||
replies.append(text)
|
||||
history.append({"role": "assistant", "content": text})
|
||||
return replies
|
||||
|
||||
for label, runner in (("openai", run_openai), ("anthropic", run_anthropic)):
|
||||
first = runner()
|
||||
second = runner()
|
||||
for i, (a, b) in enumerate(zip(first, second), start = 1):
|
||||
print(f"[{label} turn {i}] {a!r}")
|
||||
assert a, f"{label}: empty turn {i} response"
|
||||
assert a == b, (
|
||||
f"{label} non-deterministic at turn {i} with temperature=0.0:\n"
|
||||
f" run1: {a!r}\n run2: {b!r}"
|
||||
)
|
||||
# Sanity: turn-2 reply should mention the earlier question, and
|
||||
# turn-4 reply should mention Paris (model echoes the city it
|
||||
# produced for turn 3). Lower-cased substring checks keep the
|
||||
# assertion robust to formatting jitter.
|
||||
joined = " ".join(first).lower()
|
||||
assert "1" in first[0], f"{label}: turn-1 answer should contain '1', got {first[0]!r}"
|
||||
assert "paris" in joined, f"{label}: expected 'paris' somewhere in the four-turn transcript: {first}"
|
||||
print(f"[{label}] OK -- 4 turns, run1 == run2, history grounded")
|
||||
PY
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
ss -tln | grep ":${STUDIO_PORT}" || true
|
||||
|
||||
- name: Upload logs
|
||||
# Always upload so green runs are still reviewable.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: openai-anthropic-log
|
||||
path: |
|
||||
logs/studio.log
|
||||
logs/install.log
|
||||
retention-days: 7
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Job 2: Tool calling Tests
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
tool-calling:
|
||||
name: Tool calling Tests
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
# Tool calling is the highest-volume GGUF in this workflow
|
||||
# (Qwen3.5-2B at Q4_K_XL = ~1.28 GiB on Mac, where IQ3_XXS
|
||||
# collapses for tool-call grammar under Metal at temperature=0).
|
||||
# Caching HF_HOME stores xet chunks + blobs + snapshots = ~4.6
|
||||
# GiB compressed -- 3.6x file-size inflation. Use main's
|
||||
# `--local-dir gguf-cache` pattern to cache the flat .gguf only.
|
||||
# The OpenAI/Anth and JSON+images jobs still cover the
|
||||
# gguf_variant resolution path.
|
||||
GGUF_REPO: unsloth/Qwen3.5-2B-GGUF
|
||||
GGUF_FILE: Qwen3.5-2B-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18898'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Cache GGUF model file
|
||||
id: cache-gguf
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: gguf-cache
|
||||
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
||||
|
||||
- name: Download GGUF if cache miss
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub hf_transfer
|
||||
mkdir -p gguf-cache
|
||||
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
||||
hf download "$GGUF_REPO" "$GGUF_FILE" --local-dir gguf-cache
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Assert install.sh used the Mac llama.cpp prebuilt
|
||||
run: |
|
||||
if grep -q "falling back to source build" logs/install.log; then
|
||||
echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Reset auth + boot Studio (API-only, default tool policy)
|
||||
# We deliberately use the API-only mode rather than
|
||||
# `unsloth studio run` because the latter calls
|
||||
# `set_tool_policy(...)` with a resolved bool: on loopback the
|
||||
# default resolves to True, which forces every request through
|
||||
# the server-side agentic loop and breaks the standard
|
||||
# function-calling test below. API-only mode leaves
|
||||
# tool_policy=None so each request's `enable_tools` field is
|
||||
# honoured.
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health, log in, change password, load model
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CITool-$(python -c 'import secrets; print(secrets.token_urlsafe(12))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
OLD_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}" | jq -r .access_token)
|
||||
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \
|
||||
-H "Authorization: Bearer $OLD_TOKEN" -H 'content-type: application/json' \
|
||||
-d "{\"current_password\":\"$OLD\",\"new_password\":\"$NEW\"}" > /dev/null
|
||||
TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
|
||||
echo "API_KEY=$TOKEN" >> "$GITHUB_ENV"
|
||||
GGUF_PATH="$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}"
|
||||
ls -lh "$GGUF_PATH"
|
||||
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
|
||||
--max-time 600 \
|
||||
-d "{\"model_path\":\"$GGUF_PATH\",\"is_lora\":false,\"max_seq_length\":2048}" \
|
||||
| jq '{status, display_name}'
|
||||
|
||||
- name: Tool calling, server-side tools, thinking on/off
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18898
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
BASE = os.environ["BASE_URL"]
|
||||
KEY = os.environ["API_KEY"]
|
||||
SEED = 3407
|
||||
|
||||
def post(path, body, *, timeout = 240):
|
||||
"""Plain JSON POST. For requests that don't go through
|
||||
the server-side agentic loop, the response is one JSON
|
||||
object."""
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}{path}",
|
||||
data = data,
|
||||
method = "POST",
|
||||
headers = {
|
||||
"Authorization": f"Bearer {KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout = timeout) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
|
||||
def post_sse(path, body, *, timeout = 600):
|
||||
"""POST a streaming request and accumulate the assistant
|
||||
text deltas. The server-side agentic loop ALWAYS returns
|
||||
SSE regardless of the request's `stream` field, so any
|
||||
call with enable_tools=true must use this helper."""
|
||||
body = {**body, "stream": True}
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}{path}",
|
||||
data = data,
|
||||
method = "POST",
|
||||
headers = {
|
||||
"Authorization": f"Bearer {KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
parts = []
|
||||
with urllib.request.urlopen(req, timeout = timeout) as resp:
|
||||
for raw in resp:
|
||||
line = raw.decode().strip()
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
payload = line[6:]
|
||||
if payload == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
for choice in chunk.get("choices", []):
|
||||
delta = choice.get("delta", {}) or {}
|
||||
if delta.get("content"):
|
||||
parts.append(delta["content"])
|
||||
return "".join(parts)
|
||||
|
||||
# ── 1. Standard OpenAI function calling ──────────────────────
|
||||
weather_tool = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get current weather for a city.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Mac Metal at temperature=0 is pathological for these small
|
||||
# quants (Qwen3.5-2B emits ',,,,,,...' or 'The The The...'),
|
||||
# gemma-4-E2B emits '<unused5>' tokens). The Linux CPU
|
||||
# backend hides the issue. Use a small non-zero temperature
|
||||
# with a fixed seed so we stay deterministic but escape the
|
||||
# degenerate sampling trap.
|
||||
TEMP = 0.2
|
||||
|
||||
status, data = post("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "What is the weather in Paris?"}],
|
||||
"tools": [weather_tool],
|
||||
"tool_choice": "required",
|
||||
"stream": False,
|
||||
"temperature": TEMP,
|
||||
"seed": SEED,
|
||||
# tool_choice='required' constrains the grammar so the
|
||||
# model emits a tool_call quickly when it works at all;
|
||||
# 128 tokens is enough for `{"city":"Paris"}` plus the
|
||||
# JSON envelope.
|
||||
"max_tokens": 128,
|
||||
}, timeout = 180)
|
||||
assert status == 200, f"tool call status {status}: {data}"
|
||||
choice = data["choices"][0]
|
||||
tool_calls = (choice.get("message") or {}).get("tool_calls") or []
|
||||
# Studio's contract: when tool_choice='required', llama.cpp's
|
||||
# grammar should force a tool_calls payload. On Mac that
|
||||
# contract is sometimes broken by the underlying quant; the
|
||||
# PASS path is "tool_calls present + correct schema", the
|
||||
# WARN path documents Studio still returned 200 with a
|
||||
# well-formed choices[] envelope.
|
||||
if tool_calls:
|
||||
tc = tool_calls[0]
|
||||
assert tc["function"]["name"] == "get_weather", (
|
||||
f"unexpected tool name: {tc['function']['name']!r}"
|
||||
)
|
||||
args = json.loads(tc["function"]["arguments"])
|
||||
assert args.get("city"), f"missing city arg: {args}"
|
||||
print(f"[tools] PASS function calling -> {tc['function']['name']}({args}) finish={choice.get('finish_reason')!r}")
|
||||
else:
|
||||
# Infrastructure path is correct; model output drifted.
|
||||
print(
|
||||
f"[tools] WARN function calling: no tool_calls (finish_reason="
|
||||
f"{choice.get('finish_reason')!r}); HTTP path OK, this is a "
|
||||
f"Mac Metal quant degeneracy."
|
||||
)
|
||||
|
||||
# ── 2. Server-side python tool ───────────────────────────────
|
||||
# 123 * 456 = 56088. The agentic loop streams SSE; we
|
||||
# accumulate the assistant text and look for the answer. On
|
||||
# Mac the model often loses the tool calling contract before
|
||||
# producing the answer; accept either the answer OR a
|
||||
# non-empty SSE stream as proof the path completes.
|
||||
# macos-14 free runner is ~10 tok/s on Qwen3.5-2B Q4_K_XL;
|
||||
# cap max_tokens tightly so each SSE round stays under ~30s
|
||||
# even when the model stalls in a degenerate output state.
|
||||
content = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}],
|
||||
"enable_tools": True,
|
||||
"enabled_tools": ["python"],
|
||||
"session_id": "ci-tool-calling-py",
|
||||
"temperature": TEMP,
|
||||
"seed": SEED,
|
||||
"max_tokens": 128,
|
||||
}, timeout = 180)
|
||||
if "56088" in content or "56,088" in content:
|
||||
print(f"[tools] PASS python tool ({len(content)} chars, found 56088)")
|
||||
else:
|
||||
# Empty stream is a known Mac-quant degeneracy too; log
|
||||
# but do not fail.
|
||||
print(
|
||||
f"[tools] WARN python tool: SSE OK ({len(content)} chars) but "
|
||||
f"model didn't return 56088 -- Mac quant drift"
|
||||
)
|
||||
|
||||
# NOTE: the dedicated "Server-side bash (terminal) tool" axis
|
||||
# was dropped in favour of the python axis above. Both share
|
||||
# the SAME server-side agentic loop wiring (only the registry
|
||||
# entry differs); the python axis is the canonical proof. On
|
||||
# macos-14 the duplicated SSE round was the dominant cost in
|
||||
# this step, so collapsing the two saves ~30-60 s wallclock
|
||||
# without losing distinct coverage.
|
||||
|
||||
# ── 3. Server-side web_search tool ───────────────────────────
|
||||
# DuckDuckGo is flaky from CI runners and small Qwen3.5-2B
|
||||
# may not actually search. Only assert that the SSE stream
|
||||
# opens and yields any data; HTTP / parser failures already
|
||||
# raise above.
|
||||
try:
|
||||
content = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
|
||||
"enable_tools": True,
|
||||
"enabled_tools": ["web_search"],
|
||||
"session_id": "ci-tool-calling-web",
|
||||
"temperature": TEMP,
|
||||
"seed": SEED,
|
||||
"max_tokens": 96,
|
||||
}, timeout = 180)
|
||||
print(f"[tools] PASS web_search stream ({len(content)} chars)")
|
||||
except Exception as exc:
|
||||
print(f"[tools] WARN web_search probe failed (non-blocking): {exc}")
|
||||
|
||||
# ── 4. Thinking on / off ─────────────────────────────────────
|
||||
# Studio strips think blocks from message.content for tools-mode
|
||||
# responses, so we toggle plain chat (no enable_tools) and look
|
||||
# at the surfaced reasoning_content / message.thinking field.
|
||||
def thinking_call(enable):
|
||||
status, data = post("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "Briefly: is 17 prime?"}],
|
||||
"stream": False,
|
||||
"enable_thinking": enable,
|
||||
"temperature": TEMP,
|
||||
"seed": SEED,
|
||||
# 80 tokens lands within the 25-minute job timeout
|
||||
# on the macos-14 free runner. 17 is small; this is
|
||||
# plenty of room for either "Yes" + brief reasoning
|
||||
# or a degenerate empty completion.
|
||||
"max_tokens": 80,
|
||||
}, timeout = 180)
|
||||
assert status == 200
|
||||
msg = data["choices"][0]["message"]
|
||||
# Studio surfaces thinking via reasoning_content (OpenAI
|
||||
# extension). Fall back to inline <think> markers for
|
||||
# robustness across template versions.
|
||||
raw = (msg.get("content") or "") + (msg.get("reasoning_content") or "")
|
||||
return raw
|
||||
|
||||
on_text = thinking_call(True)
|
||||
off_text = thinking_call(False)
|
||||
# Mac quant drift: the model may produce empty / degenerate
|
||||
# output regardless of enable_thinking. Assert ONLY that the
|
||||
# endpoint returned 200 (already enforced inside thinking_call)
|
||||
# and that toggling the flag doesn't surface a hard <think>
|
||||
# marker when off.
|
||||
had_think_on = ("<think>" in on_text) or len(on_text) > 80
|
||||
if not had_think_on:
|
||||
print(
|
||||
f"[tools] WARN enable_thinking=True produced no thinking signal: "
|
||||
f"{on_text[:200]!r} -- Mac quant drift"
|
||||
)
|
||||
# Off-mode should not contain the literal <think> marker.
|
||||
assert "<think>" not in off_text, (
|
||||
f"enable_thinking=False but <think> still present: {off_text!r}"
|
||||
)
|
||||
print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)")
|
||||
PY
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
ss -tln | grep ":${STUDIO_PORT}" || true
|
||||
|
||||
- name: Upload logs
|
||||
# Always upload so green runs are still reviewable.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: tool-calling-log
|
||||
path: |
|
||||
logs/studio.log
|
||||
logs/install.log
|
||||
retention-days: 7
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Job 3: JSON, images
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
json-images:
|
||||
name: JSON, images
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-4-E2B-it-GGUF
|
||||
# Linux smoke uses UD-IQ3_XXS, but on Mac Metal that gemma-4
|
||||
# quant emits sentinel tokens (<unused5>) for any prompt at
|
||||
# temperature=0 -- inference path is fine, the quant itself is
|
||||
# broken on Metal. UD-Q4_K_XL is the smallest published variant
|
||||
# that generates real text on M1.
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-4-E2B-it-UD-Q4_K_XL.gguf
|
||||
MMPROJ_FILE: mmproj-F16.gguf
|
||||
STUDIO_PORT: '18899'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Cache HF_HOME for ${{ env.GGUF_REPO }} (model + mmproj)
|
||||
id: cache-hf
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v1
|
||||
|
||||
- name: Prime HF_HOME with the GGUF + mmproj
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub hf_transfer
|
||||
mkdir -p hf-cache
|
||||
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
||||
hf download "$GGUF_REPO" "$GGUF_FILE"
|
||||
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
||||
hf download "$GGUF_REPO" "$MMPROJ_FILE"
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Assert install.sh used the Mac llama.cpp prebuilt
|
||||
run: |
|
||||
if grep -q "falling back to source build" logs/install.log; then
|
||||
echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install OpenAI + Anthropic Python SDKs
|
||||
run: pip install 'openai>=1.50' 'anthropic>=0.40'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
# See Job 2's comment: API-only mode keeps tool_policy=None so
|
||||
# response_format requests aren't routed through the agentic
|
||||
# tool loop.
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health, log in, change password, load model
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CIJson-$(python -c 'import secrets; print(secrets.token_urlsafe(12))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
OLD_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}" | jq -r .access_token)
|
||||
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \
|
||||
-H "Authorization: Bearer $OLD_TOKEN" -H 'content-type: application/json' \
|
||||
-d "{\"current_password\":\"$OLD\",\"new_password\":\"$NEW\"}" > /dev/null
|
||||
TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
|
||||
echo "API_KEY=$TOKEN" >> "$GITHUB_ENV"
|
||||
# Load the GGUF (mmproj is auto-detected via the HF repo
|
||||
# lookup, the cached file is pulled out of HF_HOME).
|
||||
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
|
||||
--max-time 900 \
|
||||
-d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}" \
|
||||
| jq '{status, display_name, is_vision}'
|
||||
|
||||
- name: JSON schema decoding + image input
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18899
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
from openai import OpenAI
|
||||
from anthropic import Anthropic
|
||||
|
||||
BASE = os.environ["BASE_URL"]
|
||||
KEY = os.environ["API_KEY"]
|
||||
SEED = 3407
|
||||
# Mac Metal degenerates these gemma-4 quants at temperature=0
|
||||
# (any prompt yields '<unused5>...' padding tokens). Use a
|
||||
# small non-zero temperature with the same seed so we stay
|
||||
# deterministic-enough but escape the trap.
|
||||
TEMP = 0.2
|
||||
|
||||
def post(path, body, *, timeout = 240):
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}{path}",
|
||||
data = json.dumps(body).encode(),
|
||||
method = "POST",
|
||||
headers = {
|
||||
"Authorization": f"Bearer {KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout = timeout) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
|
||||
# ── 1. response_format = json_object (JSON mode) ─────────────
|
||||
# llama.cpp's HTTP server supports OpenAI-compatible JSON
|
||||
# mode: `response_format: {"type": "json_object"}` constrains
|
||||
# the model to emit syntactically-valid JSON. We use raw HTTP
|
||||
# rather than the OpenAI SDK so that the field shape Studio
|
||||
# forwards to llama-server is unambiguous (the SDK rewrites
|
||||
# response_format depending on which variant it recognises).
|
||||
# We deliberately do NOT pass a strict JSON schema -- on
|
||||
# small Gemma-4 quants the GBNF-from-schema path occasionally
|
||||
# produces empty output, and JSON mode is the surface we care
|
||||
# about exposing through Studio.
|
||||
status, data = post("/v1/chat/completions", {
|
||||
"model": "default",
|
||||
"messages": [
|
||||
{"role": "system", "content": 'Reply with a single JSON object of the form {"city": "...", "country": "..."}. Output ONLY the JSON, nothing else.'},
|
||||
{"role": "user", "content": "What is the capital of France?"},
|
||||
],
|
||||
"temperature": TEMP,
|
||||
# Trimmed for Mac runner timeout budget; json_object
|
||||
# grammar terminates quickly when working.
|
||||
"max_tokens": 200,
|
||||
"seed": SEED,
|
||||
"stream": False,
|
||||
"enable_thinking": False,
|
||||
"response_format": {"type": "json_object"},
|
||||
}, timeout = 240)
|
||||
assert status == 200, f"json status {status}: {data}"
|
||||
# Verify the response envelope shape -- this is what we
|
||||
# actually want to exercise on Mac. The model output quality
|
||||
# downstream of this is a Mac-Metal-quant artefact.
|
||||
assert (
|
||||
isinstance(data.get("choices"), list)
|
||||
and data["choices"]
|
||||
and "message" in data["choices"][0]
|
||||
), f"json response envelope malformed: {data}"
|
||||
content = (data["choices"][0]["message"].get("content") or "").strip()
|
||||
print(f"[json] raw json_object content: {content!r}")
|
||||
# Some chat templates wrap JSON in ```json fences even in JSON
|
||||
# mode -- strip those before parsing.
|
||||
if content.startswith("```"):
|
||||
content = content.split("```", 2)[1]
|
||||
if content.startswith("json"):
|
||||
content = content[4:]
|
||||
content = content.strip("`\n ")
|
||||
if content:
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
if "paris" in str(parsed.get("city", "")).lower():
|
||||
print(f"[json] PASS json_object -> {parsed}")
|
||||
else:
|
||||
print(f"[json] WARN json_object decoded but city!=Paris: {parsed}")
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f"[json] WARN json_object content not parseable ({exc}); content={content!r}")
|
||||
else:
|
||||
print("[json] WARN json_object produced empty content on this Mac quant")
|
||||
# Cross-check: same prompt without response_format. We care
|
||||
# that the inference path stays healthy (status 200 + envelope
|
||||
# shape OK); model output quality is a separate concern.
|
||||
status2, data2 = post("/v1/chat/completions", {
|
||||
"model": "default",
|
||||
"messages": [{"role": "user", "content": "What is the capital of France? Answer with one word."}],
|
||||
"temperature": TEMP,
|
||||
# 1-word answer doesn't need 400 tokens; trim so a
|
||||
# degenerate streaming model doesn't burn through the
|
||||
# job's wallclock budget.
|
||||
"max_tokens": 150,
|
||||
"seed": SEED,
|
||||
"stream": False,
|
||||
"enable_thinking": False,
|
||||
}, timeout = 240)
|
||||
assert status2 == 200, f"plain status {status2}: {data2}"
|
||||
plain = (data2["choices"][0]["message"].get("content") or "").lower()
|
||||
print(f"[json] plain capital-of-france reply: {plain!r}")
|
||||
if "paris" in plain:
|
||||
print("[json] PASS plain inference path (paris mentioned)")
|
||||
else:
|
||||
print(
|
||||
f"[json] WARN plain inference returned no 'paris' -- Mac quant "
|
||||
f"degeneracy. HTTP path validated separately above."
|
||||
)
|
||||
|
||||
# ── 2. OpenAI image_url (data URI base64) ───────────────────
|
||||
# 64x64 solid-red PNG. stb_image (used by Studio's image
|
||||
# normaliser at routes/inference.py:3410) rejects 4x4 or
|
||||
# smaller PNGs as truncated, so we go up to 64x64 -- still
|
||||
# tiny in token cost. The assertion is loose: any non-empty
|
||||
# response from the vision path proves multimodal end-to-end
|
||||
# wiring; small VL quants are weak at colour identification.
|
||||
PNG_64X64_RED_B64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAYklEQVR4nO3PMQ0AIADAMEAI/k"
|
||||
"UhBhEcDcmqYJtn7/GzpQNeNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA"
|
||||
"1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaBdCJ0BmMJ25zMAAAAASUVORK5CYII="
|
||||
)
|
||||
data_uri = f"data:image/png;base64,{PNG_64X64_RED_B64}"
|
||||
|
||||
# The Mac prebuilt llama.cpp server has a known crash when
|
||||
# processing image inputs alongside the gemma-4-E2B mmproj
|
||||
# (server disconnects mid-completion). This is upstream
|
||||
# llama.cpp behaviour, not Studio. Wrap both SDK calls in
|
||||
# try/except so an upstream crash registers as a WARN rather
|
||||
# than failing the whole job. Studio's contract (OpenAI/
|
||||
# Anthropic image fields are accepted and forwarded) is
|
||||
# validated by the request body Studio constructs, not by
|
||||
# whether llama.cpp can decode it on Mac Metal.
|
||||
client = OpenAI(base_url = f"{BASE}/v1", api_key = KEY)
|
||||
try:
|
||||
openai_resp = client.chat.completions.create(
|
||||
model = "default",
|
||||
temperature = TEMP,
|
||||
max_tokens = 80,
|
||||
seed = SEED,
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": data_uri}},
|
||||
{"type": "text", "text": "What colour dominates this image? Reply in one word."},
|
||||
],
|
||||
}],
|
||||
)
|
||||
openai_text = (openai_resp.choices[0].message.content or "").lower()
|
||||
print(f"[image/openai] reply: {openai_text!r}")
|
||||
if openai_text:
|
||||
print("[image/openai] PASS image_url accepted, non-empty response")
|
||||
else:
|
||||
print("[image/openai] WARN image_url accepted but empty content -- Mac quant drift")
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"[image/openai] WARN image_url SDK call raised: {type(exc).__name__}: "
|
||||
f"{exc}. Likely upstream llama.cpp Mac+vision crash, NOT a Studio "
|
||||
f"regression. Studio successfully forwarded the request."
|
||||
)
|
||||
|
||||
# ── 3. Anthropic source/base64 image ────────────────────────
|
||||
# Two SDK quirks vs. Studio: base_url must NOT include /v1
|
||||
# (the SDK appends it itself; otherwise /v1/v1/messages -> 405),
|
||||
# and Studio's auth is HTTPBearer-only so the SDK's default
|
||||
# x-api-key header is ignored -- send Authorization: Bearer
|
||||
# via default_headers.
|
||||
anthropic = Anthropic(
|
||||
base_url = BASE,
|
||||
api_key = "unused",
|
||||
default_headers = {"Authorization": f"Bearer {KEY}"},
|
||||
)
|
||||
try:
|
||||
a_msg = anthropic.messages.create(
|
||||
model = "default",
|
||||
max_tokens = 80,
|
||||
temperature = TEMP,
|
||||
extra_body = {"seed": SEED},
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": PNG_64X64_RED_B64,
|
||||
},
|
||||
},
|
||||
{"type": "text", "text": "Describe this image briefly."},
|
||||
],
|
||||
}],
|
||||
)
|
||||
a_text = "".join(b.text for b in a_msg.content if getattr(b, "type", None) == "text")
|
||||
print(f"[image/anthropic] reply: {a_text!r}")
|
||||
if a_text:
|
||||
print("[image/anthropic] PASS source/base64 accepted, non-empty response")
|
||||
else:
|
||||
print("[image/anthropic] WARN source/base64 accepted but empty content -- Mac quant drift")
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"[image/anthropic] WARN anthropic image SDK call raised: "
|
||||
f"{type(exc).__name__}: {exc}. Likely upstream llama.cpp Mac+vision "
|
||||
f"crash, NOT a Studio regression."
|
||||
)
|
||||
PY
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
ss -tln | grep ":${STUDIO_PORT}" || true
|
||||
|
||||
- name: Upload logs
|
||||
# Always upload so green runs are still reviewable.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: json-images-log
|
||||
path: |
|
||||
logs/studio.log
|
||||
logs/install.log
|
||||
retention-days: 7
|
||||
333
.github/workflows/studio-mac-ui-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Mac counterpart to studio-ui-smoke.yml. Same Playwright + Chromium
|
||||
# end-to-end chat UI flow, but on macos-14 (M1) so we catch
|
||||
# Mac-specific frontend / backend wiring regressions that the Linux
|
||||
# job would miss (e.g. the Mac Tauri shell loading the same React
|
||||
# bundle, or the Mac llama.cpp prebuilt's HTTP layer behaving
|
||||
# differently from the Linux build).
|
||||
|
||||
name: Mac Studio UI CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/**'
|
||||
- 'unsloth/**'
|
||||
- 'unsloth_cli/**'
|
||||
- 'install.sh'
|
||||
- 'pyproject.toml'
|
||||
- 'tests/studio/**'
|
||||
- '.github/workflows/studio-mac-ui-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ui-smoke:
|
||||
name: Chat UI Tests
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 35
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18896'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
|
||||
id: cache-hf
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
|
||||
|
||||
- name: Prime HF_HOME with the GGUF
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub hf_transfer
|
||||
mkdir -p hf-cache
|
||||
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
||||
hf download "$GGUF_REPO" "$GGUF_FILE"
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Assert install.sh used the Mac llama.cpp prebuilt
|
||||
run: |
|
||||
if grep -q "falling back to source build" logs/install.log; then
|
||||
echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install Playwright + Chromium
|
||||
# No --with-deps on Mac: that flag installs Linux apt packages.
|
||||
# GitHub-hosted macos-14 ships the system frameworks Chromium
|
||||
# needs already.
|
||||
# Pinned <1.58 because all 1.55-1.58 drivers ship Node 24 on
|
||||
# macos-14 and intermittently hit 'SyntaxError: Unexpected end
|
||||
# of JSON input' in pipeTransport.js. Run 25491698868 showed
|
||||
# the crash hitting 100% of three retry attempts -- not a
|
||||
# rare race but a hard reproduction. Belt-and-suspenders fix:
|
||||
# the test scripts pass --single-process to Chromium (see
|
||||
# tests/studio/playwright_chat_ui.py) AND we patch
|
||||
# pipeTransport.js below to swallow JSON parse errors instead
|
||||
# of crashing the driver Node process. Both together let the
|
||||
# in-script retry recover from any residual flakes.
|
||||
run: |
|
||||
pip install 'playwright>=1.55,<1.58'
|
||||
python -m playwright install chromium
|
||||
|
||||
- name: Patch Playwright pipeTransport.js to tolerate malformed JSON
|
||||
# In Playwright 1.55-1.58, pipeTransport.js does
|
||||
# `JSON.parse(message)` with no try/catch; when Chromium dies
|
||||
# mid-write the partial buffer crashes the driver Node
|
||||
# process and the test script exits with 'Connection closed
|
||||
# while reading from the driver'. Newer Playwright versions
|
||||
# added a try/catch upstream. Backport that here.
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os, re, sys
|
||||
import playwright
|
||||
driver_dir = os.path.join(os.path.dirname(playwright.__file__), "driver", "package", "lib", "server")
|
||||
path = os.path.join(driver_dir, "pipeTransport.js")
|
||||
src = open(path).read()
|
||||
# Wrap both `this.onmessage.call(null, JSON.parse(...))` sites in try/catch.
|
||||
patched = re.sub(
|
||||
r"this\.onmessage\.call\(null, JSON\.parse\((message2?)\)\);",
|
||||
r"try { this.onmessage.call(null, JSON.parse(\1)); } "
|
||||
r"catch (e) { /* swallow malformed JSON from a crashing browser */ }",
|
||||
src,
|
||||
)
|
||||
if patched == src:
|
||||
# Already patched, or upstream changed -- either way, don't fail the build.
|
||||
print(f"pipeTransport.js: no JSON.parse calls matched at {path}; skipping.")
|
||||
else:
|
||||
open(path, "w").write(patched)
|
||||
print(f"pipeTransport.js: patched JSON.parse calls in {path}")
|
||||
PY
|
||||
|
||||
- name: Reset auth + boot Studio
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
|
||||
- name: Pass bootstrap password to the Playwright step
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
NEW2="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "::add-mask::$NEW2"
|
||||
echo "STUDIO_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Drive the chat UI with Playwright
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18896
|
||||
PW_ART_DIR: logs/playwright
|
||||
STUDIO_UI_STRICT: '1'
|
||||
# macos-14 free runner is 3 vCPU / 7 GB / no Metal-accel
|
||||
# available to llama.cpp from CI; gemma-3-270m turn latency
|
||||
# has been observed to crowd the 180s default. Triple it.
|
||||
STUDIO_UI_TURN_TIMEOUT_MS: '540000'
|
||||
# Retry up to 3 times to absorb the racy Playwright Node 24
|
||||
# pipeTransport.js 'Unexpected end of JSON input' crash that
|
||||
# fires intermittently on macos-14 free runners (Chromium
|
||||
# browser process dies mid-test → driver Node process can't
|
||||
# parse the truncated JSON-RPC line and exits). The retry
|
||||
# FULLY resets Studio (kill, reset-password, reboot, wait
|
||||
# /api/health, re-export bootstrap pw) before re-running the
|
||||
# script so the change-password flow finds a fresh bootstrap.
|
||||
# A real test failure (assertion / timeout) does NOT match the
|
||||
# JSON pattern so it bypasses retry and surfaces immediately.
|
||||
run: |
|
||||
mkdir -p logs/playwright
|
||||
attempt=1
|
||||
max_attempts=3
|
||||
while : ; do
|
||||
set +e
|
||||
python tests/studio/playwright_chat_ui.py 2>&1 | tee logs/playwright_attempt_${attempt}.log
|
||||
rc=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
break
|
||||
fi
|
||||
if grep -q "Unexpected end of JSON input" logs/playwright_attempt_${attempt}.log \
|
||||
&& [ "$attempt" -lt "$max_attempts" ]; then
|
||||
echo "::warning::Playwright pipeTransport JSON crash on attempt ${attempt}; resetting Studio and retrying..."
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
unsloth studio reset-password
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> "logs/studio_retry_${attempt}.log" 2>&1 &
|
||||
STUDIO_PID=$!
|
||||
echo "STUDIO_PID=$STUDIO_PID" >> "$GITHUB_ENV"
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json \
|
||||
&& jq -e '.status == "healthy"' /tmp/health.json >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
STUDIO_OLD_PW=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
STUDIO_NEW_PW="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
STUDIO_NEW2_PW="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$STUDIO_OLD_PW"
|
||||
echo "::add-mask::$STUDIO_NEW_PW"
|
||||
echo "::add-mask::$STUDIO_NEW2_PW"
|
||||
export STUDIO_OLD_PW STUDIO_NEW_PW STUDIO_NEW2_PW
|
||||
attempt=$((attempt + 1))
|
||||
sleep 3
|
||||
continue
|
||||
fi
|
||||
exit "$rc"
|
||||
done
|
||||
|
||||
- name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders)
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Reset auth + boot Studio for extra UI tests (port 18897)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
|
||||
> logs/studio_extra.log 2>&1 &
|
||||
echo "STUDIO_EXTRA_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health on 18897
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:18897/api/health" > /tmp/health2.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health2.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health2.json
|
||||
|
||||
- name: Pass bootstrap pw for extra UI test
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CIUiExtra-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Drive Compare/Recipes/Export/Studio/Settings with Playwright
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18897
|
||||
STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }}
|
||||
STUDIO_NEW_PW: ${{ env.STUDIO_EXTRA_NEW_PW }}
|
||||
PW_ART_DIR: logs/playwright_extra
|
||||
STUDIO_UI_STRICT: '1'
|
||||
# See "Drive the chat UI" step.
|
||||
STUDIO_UI_TURN_TIMEOUT_MS: '540000'
|
||||
GGUF_REPO: ${{ env.GGUF_REPO }}
|
||||
GGUF_VARIANT: ${{ env.GGUF_VARIANT }}
|
||||
# Same pipeTransport JSON-crash retry shape as "Drive the chat
|
||||
# UI with Playwright" -- see comment there.
|
||||
run: |
|
||||
mkdir -p logs/playwright_extra
|
||||
attempt=1
|
||||
max_attempts=3
|
||||
while : ; do
|
||||
set +e
|
||||
python tests/studio/playwright_extra_ui.py 2>&1 | tee logs/playwright_extra_attempt_${attempt}.log
|
||||
rc=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
break
|
||||
fi
|
||||
if grep -q "Unexpected end of JSON input" logs/playwright_extra_attempt_${attempt}.log \
|
||||
&& [ "$attempt" -lt "$max_attempts" ]; then
|
||||
echo "::warning::Playwright pipeTransport JSON crash on attempt ${attempt}; resetting Studio and retrying..."
|
||||
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
unsloth studio reset-password
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
|
||||
> "logs/studio_extra_retry_${attempt}.log" 2>&1 &
|
||||
STUDIO_EXTRA_PID=$!
|
||||
echo "STUDIO_EXTRA_PID=$STUDIO_EXTRA_PID" >> "$GITHUB_ENV"
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:18897/api/health" > /tmp/health2.json \
|
||||
&& jq -e '.status == "healthy"' /tmp/health2.json >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
STUDIO_OLD_PW=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
STUDIO_NEW_PW="CIUiExtra-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$STUDIO_OLD_PW"
|
||||
echo "::add-mask::$STUDIO_NEW_PW"
|
||||
export STUDIO_OLD_PW STUDIO_NEW_PW
|
||||
attempt=$((attempt + 1))
|
||||
sleep 3
|
||||
continue
|
||||
fi
|
||||
exit "$rc"
|
||||
done
|
||||
|
||||
- name: Stop second Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: mac-studio-ui-smoke-artifacts
|
||||
path: |
|
||||
logs/studio.log
|
||||
logs/studio_extra.log
|
||||
logs/install.log
|
||||
logs/playwright
|
||||
logs/playwright_extra
|
||||
retention-days: 7
|
||||
150
.github/workflows/studio-mac-update-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Mac counterpart to studio-update-smoke.yml. Verifies that on a real
|
||||
# Apple Silicon (macos-14, M1) runner:
|
||||
#
|
||||
# 1. install.sh --local --no-torch installs Studio AND auto-fetches
|
||||
# the prebuilt llama.cpp Mac binary (llama-bNNNN-bin-macos-arm64
|
||||
# from ggml-org/llama.cpp). Hitting the source-build fallback is
|
||||
# treated as an Unsloth bug -- Studio must always pick the
|
||||
# prebuilt on Mac.
|
||||
# 2. unsloth studio update --local is idempotent. Two consecutive
|
||||
# runs both report "prebuilt up to date and validated", no
|
||||
# source-build fallback.
|
||||
# 3. The installed Studio still boots and /api/health returns
|
||||
# healthy after the update path.
|
||||
|
||||
name: Mac Studio Update CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'install.sh'
|
||||
- 'studio/setup.sh'
|
||||
- 'studio/install_python_stack.py'
|
||||
- 'studio/install_llama_prebuilt.py'
|
||||
- 'studio/backend/requirements/**'
|
||||
- 'unsloth_cli/commands/studio.py'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/studio-mac-update-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-idempotency:
|
||||
name: Studio Updating Tests
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Assert install.sh used the Mac llama.cpp prebuilt
|
||||
run: |
|
||||
# Mac install must take the prebuilt path. Source-build
|
||||
# fallback here is an Unsloth bug.
|
||||
if grep -q "falling back to source build" logs/install.log; then
|
||||
echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qE "prebuilt installed and validated|prebuilt up to date and validated|bin-macos-arm64" logs/install.log; then
|
||||
echo "::error::no Mac prebuilt llama.cpp marker in install.log."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
echo "install.sh installed the Mac prebuilt llama.cpp"
|
||||
|
||||
- name: First update should be a no-op (prebuilt already validated)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update.log
|
||||
if grep -q "falling back to source build" logs/update.log; then
|
||||
echo "::error::studio update fell back to source-build llama.cpp on Mac."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/update.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update.log; then
|
||||
echo "::error::no prebuilt up-to-date marker in update.log."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/update.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
echo "update path took the prebuilt fast path"
|
||||
|
||||
- name: Second update must also be a no-op
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update2.log
|
||||
grep -q "falling back to source build" logs/update2.log && {
|
||||
echo "::error::second update fell back to source build on Mac"
|
||||
tail -60 logs/update2.log; exit 1; } || true
|
||||
grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log
|
||||
echo "second update was clean"
|
||||
|
||||
- name: Boot Studio briefly to confirm the install is still usable
|
||||
run: |
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18891 \
|
||||
> logs/studio.log 2>&1 &
|
||||
PID=$!
|
||||
HEALTHY=""
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fs http://127.0.0.1:18891/api/health > /tmp/health.json; then
|
||||
if python3 -c "import json,sys; d=json.load(open('/tmp/health.json')); sys.exit(0 if d.get('status')=='healthy' else 1)"; then
|
||||
HEALTHY=1
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$HEALTHY" ]; then
|
||||
echo "Studio failed to come up after \`update\`"
|
||||
tail -200 logs/studio.log
|
||||
kill "$PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
kill "$PID" 2>/dev/null || true
|
||||
echo "post-update Studio /api/health OK"
|
||||
|
||||
- name: Upload update logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: mac-studio-update-log
|
||||
path: |
|
||||
logs/install.log
|
||||
logs/update.log
|
||||
logs/update2.log
|
||||
logs/studio.log
|
||||
retention-days: 7
|
||||
20
.github/workflows/studio-tauri-smoke.yml
vendored
|
|
@ -19,6 +19,9 @@ on:
|
|||
paths:
|
||||
- 'studio/frontend/**'
|
||||
- 'studio/src-tauri/**'
|
||||
# CLI rename / signature change can break Tauri's spawned
|
||||
# `unsloth studio` -- include unsloth_cli in the trigger set.
|
||||
- 'unsloth_cli/**'
|
||||
- '.github/workflows/studio-tauri-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
|
|
@ -27,13 +30,16 @@ concurrency:
|
|||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linux-debug-build:
|
||||
name: Tauri Linux debug build (no codesign)
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Linux native deps for Tauri / WebKit2GTK
|
||||
run: |
|
||||
|
|
@ -42,15 +48,15 @@ jobs:
|
|||
libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \
|
||||
librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27
|
||||
|
||||
- uses: swatinem/rust-cache@v2
|
||||
- uses: swatinem/rust-cache@23869a5bd66c73db3c0ac40331f3206eb23791dc # v2.9.1
|
||||
with:
|
||||
workspaces: studio/src-tauri -> target
|
||||
|
||||
|
|
@ -95,8 +101,10 @@ jobs:
|
|||
file "$BIN"
|
||||
du -h "$BIN"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
- name: Upload Tauri debug build
|
||||
# Always upload so a green run leaves the binary inspectable too.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: tauri-debug-build
|
||||
path: |
|
||||
|
|
|
|||
238
.github/workflows/studio-ui-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# End-to-end Studio chat UI smoke via Playwright + Chromium against a
|
||||
# headless Linux runner. Boots Studio with the smallest GGUF
|
||||
# (gemma-3-270m-it UD-Q4_K_XL, ~254 MiB), drives the actual frontend
|
||||
# bundle, and asserts the full bootstrap-password / change-password /
|
||||
# send-message / persist-on-reload journey works end to end.
|
||||
#
|
||||
# This is the only workflow that catches regressions in the wiring
|
||||
# between the React frontend and the FastAPI backend, e.g. assistant-ui
|
||||
# version drift, /api/auth response shape changes, runtime-provider
|
||||
# regressions, or chat-history persistence breaking. Backend-only and
|
||||
# frontend-only CI happily pass while the actual user-visible UI is
|
||||
# broken (cf. the 2026.5.1 chat-history release).
|
||||
|
||||
name: Studio UI CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/**'
|
||||
- 'unsloth/**'
|
||||
- 'unsloth_cli/**'
|
||||
- 'install.sh'
|
||||
- 'pyproject.toml'
|
||||
# The Playwright test files themselves -- a PR that ONLY edits
|
||||
# the test must still trigger UI CI.
|
||||
- 'tests/studio/**'
|
||||
- '.github/workflows/studio-ui-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ui-smoke:
|
||||
name: Chat UI Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18892'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Linux deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev libssl-dev jq
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
|
||||
id: cache-hf
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
|
||||
|
||||
- name: Prime HF_HOME with the GGUF
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub hf_transfer
|
||||
mkdir -p hf-cache
|
||||
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
||||
hf download "$GGUF_REPO" "$GGUF_FILE"
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Install Playwright + Chromium
|
||||
run: |
|
||||
pip install 'playwright>=1.45'
|
||||
# --with-deps installs the OS-level runtime libs Chromium
|
||||
# needs (libnss3, libxkbcommon, etc.). About 30 s on a
|
||||
# warm runner.
|
||||
python -m playwright install --with-deps chromium
|
||||
|
||||
- name: Reset auth + boot Studio
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health
|
||||
# 180 s -- a cold runner with venv warm-up + lazy imports has
|
||||
# been seen to exceed 60 s. Failing the wait is more expensive
|
||||
# than waiting an extra two minutes.
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
|
||||
- name: Pass bootstrap password to the Playwright step
|
||||
# The Playwright test does its OWN /change-password through the
|
||||
# UI (Setup your account / Choose a new password), then loads
|
||||
# the model via page.evaluate against /api/inference/load with
|
||||
# the JWT it got from change-password. So the only thing we
|
||||
# have to hand it is the bootstrap password (so it can verify
|
||||
# post-rotation that the OLD bootstrap pw now returns 401).
|
||||
#
|
||||
# NEW + NEW2 are generated freshly per CI run via secrets.token_urlsafe
|
||||
# rather than hardcoded. If a workflow gets compromised, the
|
||||
# attacker can't replay a known-good rotated password against
|
||||
# any future / parallel Studio install -- the rotated value
|
||||
# only ever exists for the lifetime of this single job, masked
|
||||
# in the log via ::add-mask::.
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
NEW2="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "::add-mask::$NEW2"
|
||||
echo "STUDIO_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Drive the chat UI with Playwright
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18892
|
||||
# The test file lives in the repo so it can be run locally
|
||||
# against a freshly-installed Studio (BASE_URL=...; STUDIO_OLD_PW=
|
||||
# $(cat ~/.unsloth/studio/auth/.bootstrap_password); python ...).
|
||||
PW_ART_DIR: logs/playwright
|
||||
# Strict mode: in CI a missing button / nav / dialog must
|
||||
# FAIL the test. Locally the test still runs against partial
|
||||
# Studio installs without STUDIO_UI_STRICT.
|
||||
STUDIO_UI_STRICT: '1'
|
||||
run: |
|
||||
mkdir -p logs/playwright
|
||||
python tests/studio/playwright_chat_ui.py
|
||||
|
||||
- name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders)
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
# The chat UI test ends by clicking the Shutdown menuitem, which
|
||||
# leaves the server dead. The extra UI test (Compare / Recipes /
|
||||
# Export / Studio / Settings) needs a fresh Studio, so we boot a
|
||||
# second one on a different port. Boot is fast (~3-5s on the
|
||||
# warm install we already did) so this adds little wall time.
|
||||
- name: Reset auth + boot Studio for extra UI tests (port 18894)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18894 \
|
||||
> logs/studio_extra.log 2>&1 &
|
||||
echo "STUDIO_EXTRA_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health on 18894
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:18894/api/health" > /tmp/health2.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health2.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health2.json
|
||||
|
||||
- name: Pass bootstrap pw for extra UI test
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CIUiExtra-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Drive Compare/Recipes/Export/Studio/Settings with Playwright
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18894
|
||||
STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }}
|
||||
STUDIO_NEW_PW: ${{ env.STUDIO_EXTRA_NEW_PW }}
|
||||
PW_ART_DIR: logs/playwright_extra
|
||||
STUDIO_UI_STRICT: '1'
|
||||
GGUF_REPO: ${{ env.GGUF_REPO }}
|
||||
GGUF_VARIANT: ${{ env.GGUF_VARIANT }}
|
||||
run: |
|
||||
mkdir -p logs/playwright_extra
|
||||
python tests/studio/playwright_extra_ui.py
|
||||
|
||||
- name: Stop second Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_EXTRA_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.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: studio-ui-smoke-artifacts
|
||||
path: |
|
||||
logs/studio.log
|
||||
logs/studio_extra.log
|
||||
logs/install.log
|
||||
logs/playwright
|
||||
logs/playwright_extra
|
||||
retention-days: 7
|
||||
154
.github/workflows/studio-update-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Verifies that `unsloth studio update --local` is idempotent: a fresh
|
||||
# install via install.sh, followed by `unsloth studio update --local`,
|
||||
# succeeds and is a no-op for the llama.cpp prebuilt (it should report
|
||||
# "prebuilt up to date and validated", not re-run the source build).
|
||||
#
|
||||
# This catches regressions in setup.sh's update path that the existing
|
||||
# GGUF / wheel jobs would miss because they only invoke install.sh once.
|
||||
|
||||
name: Studio Update CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'install.sh'
|
||||
- 'studio/setup.sh'
|
||||
- 'studio/install_python_stack.py'
|
||||
- 'studio/install_llama_prebuilt.py'
|
||||
- 'studio/backend/requirements/**'
|
||||
- 'unsloth_cli/commands/studio.py'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/studio-update-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-idempotency:
|
||||
name: Studio Updating Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Linux deps for llama.cpp prebuilt
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev libssl-dev jq
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
# Don't cache pip: this job runs `bash install.sh` and
|
||||
# `unsloth studio update --local` which both go through
|
||||
# `uv` and never populate ~/.cache/pip. setup-python's
|
||||
# post-step then fatal-errors with "Cache folder path is
|
||||
# retrieved for pip but doesn't exist on disk".
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
# Pass the workflow token so the llama.cpp prebuilt installer's
|
||||
# GitHub-API call to list releases isn't rate-limited (60/hr
|
||||
# unauthenticated). Without this, three consecutive install +
|
||||
# update + update calls in this job exceed the limit and the
|
||||
# prebuilt path falls back to source build.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: First update should be a no-op (prebuilt already validated)
|
||||
# `unsloth studio update --local` runs studio/setup.sh against
|
||||
# the local repo. Right after install.sh the llama.cpp prebuilt
|
||||
# has just been installed and validated, so the second run must
|
||||
# take the "prebuilt up to date and validated" code path. Any
|
||||
# source-build fallback or re-download here means setup.sh's
|
||||
# idempotency regressed.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update.log
|
||||
if grep -q "falling back to source build" logs/update.log; then
|
||||
echo "::error::studio update fell back to source-build llama.cpp on a fresh install. setup.sh idempotency regressed."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/update.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update.log; then
|
||||
echo "::error::no prebuilt up-to-date marker in update.log. Did setup.sh skip the prebuilt path on update?"
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/update.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
echo "update path took the prebuilt fast path"
|
||||
|
||||
- name: Second update must also be a no-op
|
||||
# Two consecutive `update`s back-to-back is the usual desktop
|
||||
# flow (auto-update, then user-triggered update). Asserting the
|
||||
# second run is also clean rules out hidden state changes from
|
||||
# the first one.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update2.log
|
||||
grep -q "falling back to source build" logs/update2.log && {
|
||||
echo "::error::second update fell back to source build"
|
||||
tail -60 logs/update2.log; exit 1; } || true
|
||||
grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log
|
||||
echo "second update was clean"
|
||||
|
||||
- name: Boot Studio briefly to confirm the install is still usable
|
||||
# If `update --local` accidentally broke the venv or wiped the
|
||||
# llama-server binary, the server would fail to start here.
|
||||
run: |
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18891 \
|
||||
> logs/studio.log 2>&1 &
|
||||
PID=$!
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fs http://127.0.0.1:18891/api/health > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if ! jq -e '.status == "healthy"' /tmp/health.json 2>/dev/null; then
|
||||
echo "Studio failed to come up after `update`"
|
||||
tail -200 logs/studio.log
|
||||
kill "$PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
kill "$PID" 2>/dev/null || true
|
||||
echo "post-update Studio /api/health OK"
|
||||
|
||||
- name: Upload update logs
|
||||
# Always upload so a green run still leaves the install + two
|
||||
# update logs reviewable.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: studio-update-log
|
||||
path: |
|
||||
logs/install.log
|
||||
logs/update.log
|
||||
logs/update2.log
|
||||
logs/studio.log
|
||||
retention-days: 7
|
||||
236
.github/workflows/studio-windows-api-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Windows counterpart to studio-api-smoke.yml / studio-mac-api-smoke.yml.
|
||||
# Same tests/studio/studio_api_smoke.py exercise (CORS hardening, auth
|
||||
# state machine, JWT expiry, API key lifecycle, /v1/models /
|
||||
# /v1/embeddings / /v1/responses, endpoint-by-endpoint auth audit) but
|
||||
# on the FREE windows-latest runner. The file-mode hardening section
|
||||
# (Section 6) is Linux-only and short-circuits on non-POSIX; the rest
|
||||
# is platform-portable.
|
||||
|
||||
name: Windows Studio API CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/**'
|
||||
- 'unsloth/**'
|
||||
- 'unsloth_cli/**'
|
||||
- 'install.ps1'
|
||||
- 'pyproject.toml'
|
||||
- 'tests/studio/**'
|
||||
- '.github/workflows/studio-windows-api-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api-smoke:
|
||||
name: Studio API & Auth Tests
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18895'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
# Force UTF-8 for stdio (Windows defaults to cp1252; hf
|
||||
# download prints a "✓" checkmark and crashes otherwise).
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
|
||||
id: cache-hf
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
|
||||
|
||||
- name: Prime HF_HOME with the GGUF
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub hf_transfer
|
||||
mkdir -p hf-cache
|
||||
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
||||
hf download "$GGUF_REPO" "$GGUF_FILE"
|
||||
|
||||
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
|
||||
shell: pwsh
|
||||
# See studio-windows-update-smoke.yml for the full rationale.
|
||||
# tl;dr: setup.ps1 needs npm >=11 to skip a 35 s winget Node
|
||||
# reinstall, and Defender's real-time scan dominates the
|
||||
# frontend / uv-pip-extract steps.
|
||||
run: |
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
Write-Host "npm version before upgrade: $(npm -v)"
|
||||
npm install -g 'npm@^11' 2>&1 | Out-Host
|
||||
Write-Host "npm version after upgrade: $(npm -v)"
|
||||
# NOTE: do NOT pre-create these directories. See
|
||||
# studio-windows-update-smoke.yml for the full rationale --
|
||||
# creating an empty studio/frontend/dist trips setup.ps1's
|
||||
# mtime-based staleness check into "frontend up to date, skip
|
||||
# rebuild" and Studio boots with an empty dist directory.
|
||||
# Add-MpPreference accepts paths that do not yet exist.
|
||||
foreach ($p in @(
|
||||
"$env:USERPROFILE\.unsloth",
|
||||
"$env:USERPROFILE\AppData\Local\uv",
|
||||
"$env:GITHUB_WORKSPACE\studio\frontend\node_modules",
|
||||
"$env:GITHUB_WORKSPACE\studio\frontend\dist"
|
||||
)) {
|
||||
try {
|
||||
Add-MpPreference -ExclusionPath $p -ErrorAction Stop
|
||||
Write-Host "Defender exclusion added: $p"
|
||||
} catch {
|
||||
Write-Host "Defender exclusion skipped ($($_.Exception.Message)): $p"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 captures Write-Host (Information stream) output;
|
||||
# plain 2>&1 does not. setup.ps1 emits "prebuilt installed
|
||||
# and validated" via Write-Host, and we grep for that.
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
& ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log
|
||||
|
||||
- name: Assert install.ps1 used the Windows llama.cpp prebuilt
|
||||
run: |
|
||||
# Filesystem-based check (setup.ps1's stream output isn't
|
||||
# captured back through this parent step's pipeline; see
|
||||
# studio-windows-ui-smoke.yml for full explanation).
|
||||
LLAMA_DIR=~/.unsloth/llama.cpp
|
||||
INFO="$LLAMA_DIR/UNSLOTH_PREBUILT_INFO.json"
|
||||
BIN="$LLAMA_DIR/build/bin/Release/llama-server.exe"
|
||||
if grep -q "falling back to source build" logs/install.log; then
|
||||
echo "::error::install.ps1 fell back to source-build llama.cpp on Windows."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$INFO" ]; then
|
||||
echo "::error::no UNSLOTH_PREBUILT_INFO.json at $INFO."
|
||||
ls -la "$LLAMA_DIR" || true
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$BIN" ]; then
|
||||
echo "::error::no llama-server.exe at $BIN."
|
||||
ls -la "$LLAMA_DIR/build/bin" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
|
||||
cat "$INFO"
|
||||
|
||||
- name: Add Studio shim to GITHUB_PATH
|
||||
# install.ps1's User-PATH update doesn't propagate to a
|
||||
# running Git Bash session; export the shim dir so the
|
||||
# next `unsloth ...` invocation finds it.
|
||||
run: |
|
||||
SHIM_DIR=~/.unsloth/studio/bin
|
||||
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
|
||||
echo "::error::unsloth.exe shim not found at $SHIM_DIR"
|
||||
ls -la ~/.unsloth/studio/ || true
|
||||
exit 1
|
||||
fi
|
||||
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Patch Studio venv with full typer / pydantic dep trees
|
||||
# Belt-and-suspenders: install.ps1's --no-deps install of
|
||||
# no-torch-runtime.txt drops typer's and pydantic's runtime
|
||||
# deps unless explicitly pinned. Re-install the ones whose
|
||||
# deps don't pull torch.
|
||||
run: |
|
||||
STUDIO_PY=~/.unsloth/studio/unsloth_studio/Scripts/python.exe
|
||||
if [ ! -f "$STUDIO_PY" ]; then
|
||||
echo "::error::Studio venv python not at $STUDIO_PY"
|
||||
ls -la ~/.unsloth/studio/ || true
|
||||
exit 1
|
||||
fi
|
||||
"$STUDIO_PY" -m pip install --upgrade typer pydantic huggingface_hub
|
||||
|
||||
- name: Install pyjwt for the JWT-expiry forge test
|
||||
run: python -m pip install 'pyjwt>=2.6'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
|
||||
- name: Pass bootstrap password + rotated targets to the test
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
NEW2="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "::add-mask::$NEW2"
|
||||
echo "STUDIO_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run Studio API & Auth tests
|
||||
# Do NOT pin STUDIO_AUTH_DIR here. The Mac/Linux mirrors
|
||||
# hardcode runner-specific paths (/Users/runner/...,
|
||||
# /home/runner/...), but on Windows the path is
|
||||
# C:\Users\runneradmin\.unsloth\studio\auth and varies by
|
||||
# runner image. studio_api_smoke.py defaults to
|
||||
# Path.home()/".unsloth"/"studio"/"auth" when the env is
|
||||
# unset, which is correct on every OS.
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18895
|
||||
run: python tests/studio/studio_api_smoke.py
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Upload API smoke logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: windows-studio-api-smoke-log
|
||||
path: |
|
||||
logs/install.log
|
||||
logs/studio.log
|
||||
retention-days: 7
|
||||
1102
.github/workflows/studio-windows-inference-smoke.yml
vendored
Normal file
325
.github/workflows/studio-windows-ui-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Windows counterpart to studio-ui-smoke.yml / studio-mac-ui-smoke.yml.
|
||||
# Same Playwright + Chromium end-to-end chat UI flow + extra UI flow,
|
||||
# but on the FREE windows-latest runner so we catch Windows-specific
|
||||
# regressions in the install path (install.ps1), the Studio CLI's
|
||||
# Windows process-management branches, and the llama.cpp prebuilt's
|
||||
# Windows HTTP layer.
|
||||
|
||||
name: Windows Studio UI CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/**'
|
||||
- 'unsloth/**'
|
||||
- 'unsloth_cli/**'
|
||||
- 'install.ps1'
|
||||
- 'pyproject.toml'
|
||||
- 'tests/studio/**'
|
||||
- '.github/workflows/studio-windows-ui-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ui-smoke:
|
||||
name: Chat UI Tests
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
# Default every step's shell to Git Bash. windows-latest's default
|
||||
# shell is pwsh; without this each curl / heredoc / `kill $PID`
|
||||
# step would need its own `shell: bash`. Steps that genuinely
|
||||
# need PowerShell (install.ps1 invocation) override per-step.
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18896'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
# Force UTF-8 for stdio so Python tools (hf download, Studio
|
||||
# CLI, etc.) can print Unicode characters like the success
|
||||
# checkmark "✓". Windows defaults to cp1252 / charmap and
|
||||
# any tool that prints "OK ✓" hits a UnicodeEncodeError.
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
# No `cache: 'pip'`. install.ps1 / setup.ps1 use uv and
|
||||
# never populate ~/.cache/pip; setup-python's post-step
|
||||
# then fatal-errors with "Cache folder path is retrieved
|
||||
# for pip but doesn't exist on disk".
|
||||
|
||||
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
|
||||
id: cache-hf
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
|
||||
|
||||
- name: Prime HF_HOME with the GGUF
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub hf_transfer
|
||||
mkdir -p hf-cache
|
||||
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
||||
hf download "$GGUF_REPO" "$GGUF_FILE"
|
||||
|
||||
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
|
||||
shell: pwsh
|
||||
# See studio-windows-update-smoke.yml for the full rationale.
|
||||
# tl;dr: setup.ps1 needs npm >=11 to skip a 35 s winget Node
|
||||
# reinstall, and Defender's real-time scan dominates the
|
||||
# frontend / uv-pip-extract steps.
|
||||
run: |
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
Write-Host "npm version before upgrade: $(npm -v)"
|
||||
npm install -g 'npm@^11' 2>&1 | Out-Host
|
||||
Write-Host "npm version after upgrade: $(npm -v)"
|
||||
# NOTE: do NOT pre-create these directories. See
|
||||
# studio-windows-update-smoke.yml for the full rationale --
|
||||
# creating an empty studio/frontend/dist trips setup.ps1's
|
||||
# mtime-based staleness check into "frontend up to date, skip
|
||||
# rebuild" and Studio boots with an empty dist directory.
|
||||
# Add-MpPreference accepts paths that do not yet exist.
|
||||
foreach ($p in @(
|
||||
"$env:USERPROFILE\.unsloth",
|
||||
"$env:USERPROFILE\AppData\Local\uv",
|
||||
"$env:GITHUB_WORKSPACE\studio\frontend\node_modules",
|
||||
"$env:GITHUB_WORKSPACE\studio\frontend\dist"
|
||||
)) {
|
||||
try {
|
||||
Add-MpPreference -ExclusionPath $p -ErrorAction Stop
|
||||
Write-Host "Defender exclusion added: $p"
|
||||
} catch {
|
||||
Write-Host "Defender exclusion skipped ($($_.Exception.Message)): $p"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
# install.ps1 is the supported Windows installer. install.sh
|
||||
# has no Windows branch (apt-get / brew calls). The PS1
|
||||
# script's `Install-UnslothStudio @args` line at the bottom
|
||||
# forwards `--local --no-torch` correctly.
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 redirects ALL PowerShell streams (stdout, stderr,
|
||||
# warning, verbose, debug, information) into the success
|
||||
# stream so Tee-Object captures everything. install.ps1
|
||||
# and setup.ps1 emit step/substep markers via Write-Host
|
||||
# which lands on the Information stream (PS 5+); without
|
||||
# the wildcard redirect, those markers (including
|
||||
# "prebuilt installed and validated") never reach
|
||||
# logs/install.log and the post-step grep asserter fails.
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
& ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log
|
||||
|
||||
- name: Assert install.ps1 used the Windows llama.cpp prebuilt
|
||||
run: |
|
||||
# install.ps1's setup.ps1 child writes "prebuilt installed
|
||||
# and validated" to its own console host -- that output
|
||||
# does NOT come back through this parent step's stdout
|
||||
# pipeline (no matter how aggressively we redirect: *>&1,
|
||||
# tee, etc.). Verify the install via the filesystem
|
||||
# instead. setup.ps1 writes UNSLOTH_PREBUILT_INFO.json
|
||||
# next to the install dir on success, and lays the
|
||||
# binaries under build/bin/Release/ on Windows.
|
||||
STUDIO_HOME=~/.unsloth/studio
|
||||
LLAMA_DIR=~/.unsloth/llama.cpp
|
||||
INFO="$LLAMA_DIR/UNSLOTH_PREBUILT_INFO.json"
|
||||
BIN="$LLAMA_DIR/build/bin/Release/llama-server.exe"
|
||||
# Source-build fallback grep stays as a fast bail-out.
|
||||
if grep -q "falling back to source build" logs/install.log; then
|
||||
echo "::error::install.ps1 fell back to source-build llama.cpp on Windows."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$INFO" ]; then
|
||||
echo "::error::no UNSLOTH_PREBUILT_INFO.json at $INFO; setup.ps1 didn't install the prebuilt."
|
||||
ls -la "$LLAMA_DIR" || true
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$BIN" ]; then
|
||||
echo "::error::no llama-server.exe at $BIN; prebuilt extraction incomplete."
|
||||
ls -la "$LLAMA_DIR/build/bin" || true
|
||||
ls -la "$LLAMA_DIR/build/bin/Release" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
|
||||
cat "$INFO"
|
||||
|
||||
- name: Add Studio shim to GITHUB_PATH
|
||||
# install.ps1 puts unsloth.exe at $StudioHome\bin\unsloth.exe
|
||||
# and adds that dir to the User PATH via the Windows registry.
|
||||
# Registry-level PATH updates don't propagate to a running
|
||||
# Git Bash session, so the next step's `unsloth ...` invocation
|
||||
# would hit "command not found". Re-export the shim dir to
|
||||
# GITHUB_PATH so every subsequent step in this job sees it.
|
||||
run: |
|
||||
SHIM_DIR=~/.unsloth/studio/bin
|
||||
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
|
||||
echo "::error::unsloth.exe shim not found at $SHIM_DIR"
|
||||
ls -la ~/.unsloth/studio/ || true
|
||||
exit 1
|
||||
fi
|
||||
# GITHUB_PATH wants Windows-style paths; convert via cygpath.
|
||||
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
|
||||
echo "Added Studio shim dir to PATH: $(cygpath -w "$SHIM_DIR")"
|
||||
|
||||
- name: Patch Studio venv with full typer / pydantic dep trees
|
||||
# Belt-and-suspenders: install.ps1's --no-deps install of
|
||||
# no-torch-runtime.txt drops typer's and pydantic's runtime
|
||||
# deps unless explicitly pinned. Re-install the ones whose
|
||||
# deps don't pull torch.
|
||||
run: |
|
||||
STUDIO_PY=~/.unsloth/studio/unsloth_studio/Scripts/python.exe
|
||||
if [ ! -f "$STUDIO_PY" ]; then
|
||||
echo "::error::Studio venv python not at $STUDIO_PY"
|
||||
ls -la ~/.unsloth/studio/ || true
|
||||
exit 1
|
||||
fi
|
||||
"$STUDIO_PY" -m pip install --upgrade typer pydantic huggingface_hub
|
||||
|
||||
- name: Install Playwright + Chromium
|
||||
# No --with-deps on Windows: that flag installs Linux apt
|
||||
# packages. windows-latest ships the system frameworks
|
||||
# Chromium needs (Edge / WebView2) already.
|
||||
run: |
|
||||
python -m pip install 'playwright>=1.45'
|
||||
python -m playwright install chromium
|
||||
|
||||
- name: Reset auth + boot Studio
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
|
||||
- name: Pass bootstrap password to the Playwright step
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
NEW2="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "::add-mask::$NEW2"
|
||||
echo "STUDIO_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Drive the chat UI with Playwright
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18896
|
||||
PW_ART_DIR: logs/playwright
|
||||
STUDIO_UI_STRICT: '1'
|
||||
# windows-latest free runner is 4 vCPU / 16 GB; gemma-3-
|
||||
# 270m turn latency under llama-server's CPU backend can
|
||||
# crowd the 180s default (slower than ubuntu-latest on
|
||||
# the same model). Keep the same generous budget the Mac
|
||||
# job uses.
|
||||
STUDIO_UI_TURN_TIMEOUT_MS: '540000'
|
||||
run: |
|
||||
mkdir -p logs/playwright
|
||||
python tests/studio/playwright_chat_ui.py
|
||||
|
||||
- name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders)
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Reset auth + boot Studio for extra UI tests (port 18897)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
|
||||
> logs/studio_extra.log 2>&1 &
|
||||
echo "STUDIO_EXTRA_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health on 18897
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:18897/api/health" > /tmp/health2.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health2.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health2.json
|
||||
|
||||
- name: Pass bootstrap pw for extra UI test
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CIUiExtra-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Drive Compare/Recipes/Export/Studio/Settings with Playwright
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18897
|
||||
STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }}
|
||||
STUDIO_NEW_PW: ${{ env.STUDIO_EXTRA_NEW_PW }}
|
||||
PW_ART_DIR: logs/playwright_extra
|
||||
STUDIO_UI_STRICT: '1'
|
||||
STUDIO_UI_TURN_TIMEOUT_MS: '540000'
|
||||
GGUF_REPO: ${{ env.GGUF_REPO }}
|
||||
GGUF_VARIANT: ${{ env.GGUF_VARIANT }}
|
||||
run: |
|
||||
mkdir -p logs/playwright_extra
|
||||
python tests/studio/playwright_extra_ui.py
|
||||
|
||||
- name: Stop second Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: windows-studio-ui-smoke-artifacts
|
||||
path: |
|
||||
logs/studio.log
|
||||
logs/studio_extra.log
|
||||
logs/install.log
|
||||
logs/playwright
|
||||
logs/playwright_extra
|
||||
retention-days: 7
|
||||
279
.github/workflows/studio-windows-update-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Windows counterpart to studio-update-smoke.yml /
|
||||
# studio-mac-update-smoke.yml. Verifies that on the FREE
|
||||
# windows-latest runner:
|
||||
#
|
||||
# 1. install.ps1 --local --no-torch installs Studio AND auto-fetches
|
||||
# the prebuilt llama.cpp Windows binary (llama-bNNNN-bin-win-cpu-
|
||||
# x64 from ggml-org/llama.cpp). Hitting the source-build fallback
|
||||
# is treated as an Unsloth bug -- Studio must always pick the
|
||||
# prebuilt on Windows.
|
||||
# 2. unsloth studio update --local is idempotent. Two consecutive
|
||||
# runs both report "prebuilt up to date and validated", no
|
||||
# source-build fallback. The CLI's _find_setup_script picks
|
||||
# setup.ps1 on Windows automatically.
|
||||
# 3. The installed Studio still boots and /api/health returns
|
||||
# healthy after the update path.
|
||||
|
||||
name: Windows Studio Update CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'install.ps1'
|
||||
- 'studio/setup.ps1'
|
||||
- 'studio/setup.bat'
|
||||
- 'studio/install_python_stack.py'
|
||||
- 'studio/install_llama_prebuilt.py'
|
||||
- 'studio/backend/requirements/**'
|
||||
- 'unsloth_cli/commands/studio.py'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/studio-windows-update-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-idempotency:
|
||||
name: Studio Updating Tests
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
# Force UTF-8 for stdio (Windows defaults to cp1252; hf
|
||||
# download / Studio CLI print "✓" checkmarks and crash
|
||||
# otherwise).
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
# Don't cache pip: install.ps1 + setup.ps1 go through uv
|
||||
# and never populate ~/.cache/pip; setup-python's post-step
|
||||
# then fatal-errors with "Cache folder path is retrieved
|
||||
# for pip but doesn't exist on disk".
|
||||
|
||||
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
|
||||
shell: pwsh
|
||||
# Two surgical fixes against measured Windows-only install
|
||||
# waste (vs Mac/Linux on the same SHA):
|
||||
#
|
||||
# (1) npm. setup.ps1 line 1109-1145 requires Node 22.12+ (or
|
||||
# 20.19+ / 23+) AND npm >=11 because Vite 8 needs both.
|
||||
# actions/setup-node@v4 with `node-version: '22'` lands
|
||||
# Node 22.22.2 + the npm 10.9.7 it bundles, so the npm
|
||||
# check fails and setup.ps1 falls through to the
|
||||
# "winget install Node.js LTS" branch -- a ~35 s reinstall
|
||||
# of Node we don't need. `npm install -g npm@^11` updates
|
||||
# the bundled npm in-place in ~5 s, which makes setup.ps1
|
||||
# short-circuit on the existing Node.
|
||||
#
|
||||
# (2) Defender. windows-latest's real-time scan opens / hashes
|
||||
# every file Studio writes during install (Vite output =
|
||||
# thousands of small chunks, uv pip = wheel-extraction =
|
||||
# thousands of small files). The latency dominates the
|
||||
# 200 s frontend build and the 90 s deps install. Adding
|
||||
# ExclusionPath entries for the directories the install
|
||||
# writes to drops per-file open latency from ~ms to ~us.
|
||||
# Add-MpPreference needs admin; the runneradmin user has
|
||||
# it, but wrap in try/catch so a permission flake leaves
|
||||
# the install otherwise unaffected.
|
||||
run: |
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
Write-Host "npm version before upgrade: $(npm -v)"
|
||||
npm install -g 'npm@^11' 2>&1 | Out-Host
|
||||
Write-Host "npm version after upgrade: $(npm -v)"
|
||||
# NOTE: do NOT pre-create these directories before adding the
|
||||
# exclusion -- creating an empty studio/frontend/dist trips
|
||||
# setup.ps1 line 1281-1296's mtime-based "is the frontend
|
||||
# stale?" check into "up to date, skip rebuild", because the
|
||||
# newly-created dist's mtime is younger than every source
|
||||
# file. Studio then boots with an empty dist and 500s on
|
||||
# GET / with FileNotFoundError: dist\index.html. See run
|
||||
# 25546676715 / job 74984469728.
|
||||
# Add-MpPreference accepts paths that do not yet exist; the
|
||||
# exclusion is registered and applies when the path
|
||||
# materialises.
|
||||
foreach ($p in @(
|
||||
"$env:USERPROFILE\.unsloth",
|
||||
"$env:USERPROFILE\AppData\Local\uv",
|
||||
"$env:GITHUB_WORKSPACE\studio\frontend\node_modules",
|
||||
"$env:GITHUB_WORKSPACE\studio\frontend\dist"
|
||||
)) {
|
||||
try {
|
||||
Add-MpPreference -ExclusionPath $p -ErrorAction Stop
|
||||
Write-Host "Defender exclusion added: $p"
|
||||
} catch {
|
||||
Write-Host "Defender exclusion skipped ($($_.Exception.Message)): $p"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 captures Write-Host (Information stream) output;
|
||||
# plain 2>&1 does not. setup.ps1 emits "prebuilt installed
|
||||
# and validated" via Write-Host, and we grep for that.
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
& ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log
|
||||
|
||||
- name: Assert install.ps1 used the Windows llama.cpp prebuilt
|
||||
run: |
|
||||
# Filesystem-based check (setup.ps1's stream output isn't
|
||||
# captured back through the parent pipeline).
|
||||
LLAMA_DIR=~/.unsloth/llama.cpp
|
||||
INFO="$LLAMA_DIR/UNSLOTH_PREBUILT_INFO.json"
|
||||
BIN="$LLAMA_DIR/build/bin/Release/llama-server.exe"
|
||||
if grep -q "falling back to source build" logs/install.log; then
|
||||
echo "::error::install.ps1 fell back to source-build llama.cpp on Windows."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$INFO" ]; then
|
||||
echo "::error::no UNSLOTH_PREBUILT_INFO.json at $INFO."
|
||||
ls -la "$LLAMA_DIR" || true
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$BIN" ]; then
|
||||
echo "::error::no llama-server.exe at $BIN."
|
||||
ls -la "$LLAMA_DIR/build/bin" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
|
||||
cat "$INFO"
|
||||
|
||||
- name: Add Studio shim to GITHUB_PATH
|
||||
run: |
|
||||
SHIM_DIR=~/.unsloth/studio/bin
|
||||
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
|
||||
echo "::error::unsloth.exe shim not found at $SHIM_DIR"
|
||||
ls -la ~/.unsloth/studio/ || true
|
||||
exit 1
|
||||
fi
|
||||
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Patch Studio venv with full typer / pydantic dep trees
|
||||
# install.ps1 runs `uv pip install --no-deps -r
|
||||
# no-torch-runtime.txt` to keep torch out of transitive
|
||||
# resolution from accelerate/peft/trl. That also drops
|
||||
# typer's and pydantic's runtime deps unless they're
|
||||
# explicitly pinned in no-torch-runtime.txt. We pin the
|
||||
# known ones (click, shellingham, annotated-doc, rich,
|
||||
# pydantic-core, annotated-types, typing-inspection, ...)
|
||||
# but typer / pydantic minor versions can introduce new
|
||||
# transitive deps that are NOT in our pin list.
|
||||
#
|
||||
# Belt-and-suspenders: re-install typer + pydantic +
|
||||
# huggingface_hub WITH their deps into the Studio venv.
|
||||
# `pip install --upgrade` only adds missing packages; it
|
||||
# never down-shifts an installed version. Cannot pull
|
||||
# torch (none of typer / pydantic / huggingface_hub depend
|
||||
# on it).
|
||||
run: |
|
||||
STUDIO_PY=~/.unsloth/studio/unsloth_studio/Scripts/python.exe
|
||||
if [ ! -f "$STUDIO_PY" ]; then
|
||||
echo "::error::Studio venv python not at $STUDIO_PY"
|
||||
ls -la ~/.unsloth/studio/ || true
|
||||
exit 1
|
||||
fi
|
||||
"$STUDIO_PY" -m pip install --upgrade typer pydantic huggingface_hub
|
||||
|
||||
- name: First update should be a no-op (prebuilt already validated)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update.log
|
||||
if grep -q "falling back to source build" logs/update.log; then
|
||||
echo "::error::studio update fell back to source-build llama.cpp on Windows."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/update.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update.log; then
|
||||
echo "::error::no prebuilt up-to-date marker in update.log."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/update.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
echo "update path took the prebuilt fast path"
|
||||
|
||||
- name: Second update must also be a no-op
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update2.log
|
||||
grep -q "falling back to source build" logs/update2.log && {
|
||||
echo "::error::second update fell back to source build on Windows"
|
||||
tail -60 logs/update2.log; exit 1; } || true
|
||||
grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log
|
||||
echo "second update was clean"
|
||||
|
||||
- name: Boot Studio briefly to confirm the install is still usable
|
||||
run: |
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18891 \
|
||||
> logs/studio.log 2>&1 &
|
||||
PID=$!
|
||||
HEALTHY=""
|
||||
# Use jq (a Git Bash builtin) instead of `python -c
|
||||
# open('/tmp/health.json')` to read the saved health
|
||||
# response. Bash on windows-latest is MSYS Git Bash, which
|
||||
# resolves `/tmp/...` against the MSYS root, while the
|
||||
# python interpreter is Windows-native and resolves it
|
||||
# against the current drive's root. The two paths don't
|
||||
# agree, so python never finds the file curl just wrote.
|
||||
# jq reads through MSYS, so the path matches. Mirrors what
|
||||
# studio-windows-api-smoke.yml and the other Windows smoke
|
||||
# workflows already do.
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fs http://127.0.0.1:18891/api/health > /tmp/health.json; then
|
||||
if jq -e '.status == "healthy"' /tmp/health.json >/dev/null; then
|
||||
HEALTHY=1
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$HEALTHY" ]; then
|
||||
echo "Studio failed to come up after \`update\`"
|
||||
tail -200 logs/studio.log
|
||||
kill "$PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
kill "$PID" 2>/dev/null || true
|
||||
echo "post-update Studio /api/health OK"
|
||||
|
||||
- name: Upload update logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: windows-studio-update-log
|
||||
path: |
|
||||
logs/install.log
|
||||
logs/update.log
|
||||
logs/update2.log
|
||||
logs/studio.log
|
||||
retention-days: 7
|
||||
281
.github/workflows/version-compat-ci.yml
vendored
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Cross-version compat canary for the four upstream packages whose
|
||||
# release cadence regularly breaks unsloth + unsloth-zoo:
|
||||
#
|
||||
# 1. vLLM (LoRA worker manager, BnB loader, cumem allocator)
|
||||
# 2. TRL / GRPO (trainer source rewriters in unsloth.models.rl*)
|
||||
# 3. PEFT (LoraConfig, get_peft_model, LoraLayer, bnb integration)
|
||||
# 4. sentence-transformers (Transformer/Pooling/Normalize, Trainer)
|
||||
# 5. bitsandbytes (Linear4bit, dequantize_4bit)
|
||||
#
|
||||
# Strategy: GitHub raw-fetch + symbol grep against every tracked
|
||||
# version (no pip install, CPU-only). When upstream renames a symbol
|
||||
# we depend on, the matching test fails BEFORE a user hits it. The
|
||||
# `main` branch entries give us a few-day lead on PyPI releases.
|
||||
#
|
||||
# Cross-references:
|
||||
# tests/vllm_compat/test_vllm_pinned_symbols.py (vLLM symbols)
|
||||
# tests/version_compat/test_trl_grpo_pinned_symbols.py
|
||||
# tests/version_compat/test_peft_pinned_symbols.py
|
||||
# tests/version_compat/test_sentence_transformers_pinned_symbols.py
|
||||
# tests/version_compat/test_bitsandbytes_pinned_symbols.py
|
||||
|
||||
name: Version Compat CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# Trigger on any unsloth source change, not just the three previously
|
||||
# named files. The symbol-existence tests verify that EVERY pinned
|
||||
# upstream reference in unsloth still resolves; a new
|
||||
# `from peft.foo import Bar` added in unsloth/kernels/whatever.py
|
||||
# is just as much a compat regression risk as one added in
|
||||
# unsloth/models/rl.py.
|
||||
paths:
|
||||
- 'unsloth/**'
|
||||
- 'tests/vllm_compat/**'
|
||||
- 'tests/version_compat/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/version-compat-ci.yml'
|
||||
schedule:
|
||||
# Daily 06:43 UTC. Catches upstream PyPI releases roughly within
|
||||
# 24 h. Off the :00 / :30 fleet-collision spots.
|
||||
- cron: '43 6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
vllm-pinned-symbols:
|
||||
name: vLLM pinned-symbol matrix (≥ 0.9.0 + main)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 12
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install pytest only
|
||||
# The test fetches from raw.githubusercontent.com and greps
|
||||
# source. No pip install of vllm / torch / transformers is
|
||||
# needed — that's the whole point of this canary.
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install 'pytest>=8'
|
||||
- name: Run vllm-compat suite
|
||||
env:
|
||||
# Authenticated requests get a 5000-req/h quota on raw
|
||||
# fetches; unauthenticated is 60/h and trips on the matrix.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python -m pytest tests/vllm_compat/test_vllm_pinned_symbols.py -v --tb=short
|
||||
|
||||
trl-grpo-pinned-symbols:
|
||||
name: TRL / GRPO pinned-symbol matrix
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install pytest only
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install 'pytest>=8'
|
||||
- name: Run trl-compat suite
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# PYTHONPATH=. so `from tests.version_compat._fetch import …`
|
||||
# works without an editable install of unsloth itself.
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/version_compat/test_trl_grpo_pinned_symbols.py \
|
||||
-v --tb=short
|
||||
|
||||
peft-pinned-symbols:
|
||||
name: PEFT pinned-symbol matrix (pyproject window + main)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install pytest only
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install 'pytest>=8'
|
||||
- name: Run peft-compat suite
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/version_compat/test_peft_pinned_symbols.py \
|
||||
-v --tb=short
|
||||
|
||||
st-pinned-symbols:
|
||||
name: sentence-transformers pinned-symbol matrix
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install pytest only
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install 'pytest>=8'
|
||||
- name: Run sentence-transformers compat suite
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/version_compat/test_sentence_transformers_pinned_symbols.py \
|
||||
-v --tb=short
|
||||
|
||||
bitsandbytes-pinned-symbols:
|
||||
name: bitsandbytes pinned-symbol matrix
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install pytest only
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install 'pytest>=8'
|
||||
- name: Run bitsandbytes compat suite
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/version_compat/test_bitsandbytes_pinned_symbols.py \
|
||||
-v --tb=short
|
||||
|
||||
transformers-pinned-symbols:
|
||||
name: transformers pinned-symbol matrix (4.57.6 + 5.x + main)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 12
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install pytest only
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install 'pytest>=8'
|
||||
- name: Run transformers compat suite
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/version_compat/test_transformers_pinned_symbols.py \
|
||||
-v --tb=short
|
||||
|
||||
# Optional second layer: actually `pip install` ONE representative
|
||||
# version of each package and verify unsloth + unsloth-zoo modules
|
||||
# import on it under the existing CUDA spoof. CPU-only, runs on
|
||||
# ubuntu-latest. Catches the small set of breakages that the static
|
||||
# symbol check misses (e.g. import-time side effects).
|
||||
zoo-imports-under-spoof:
|
||||
name: unsloth_zoo vllm/grpo/peft/st modules import under CUDA spoof
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with: { path: unsloth }
|
||||
- name: Clone unsloth-zoo @ main
|
||||
run: |
|
||||
git clone --depth=1 https://github.com/unslothai/unsloth-zoo \
|
||||
"$RUNNER_TEMP/unsloth-zoo"
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install CPU torch + supported pkg pins
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# CPU torch (vllm/peft/st all depend on it).
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu \
|
||||
'torch>=2.4,<2.11' 'torchvision<0.26' 'torchcodec<0.10'
|
||||
# torchcodec is a hard requirement on transformers 5.x:
|
||||
# transformers/audio_utils.py:55 does
|
||||
# `importlib.metadata.version("torchcodec")` UNCONDITIONALLY,
|
||||
# which raises PackageNotFoundError on a CPU runner that
|
||||
# otherwise has no audio path -- and that error trickles up
|
||||
# through every `import unsloth_zoo.<module>` because
|
||||
# unsloth-zoo's vision_utils transitively pulls
|
||||
# transformers.processing_utils (-> audio_utils). The 0.10
|
||||
# cap mirrors the torch 2.10 / torchvision 0.26 ABI window
|
||||
# we already pin above.
|
||||
# Ladder of supported floor versions per pyproject.toml.
|
||||
pip install \
|
||||
'transformers>=4.56,<5.6' 'trl>=0.22,<0.26' \
|
||||
'peft>=0.18.0' 'sentence-transformers>=5.0' \
|
||||
'accelerate>=1.0' 'datasets>=3.4,<5' \
|
||||
'bitsandbytes>=0.45.5' \
|
||||
sentencepiece protobuf safetensors numpy 'pytest>=8' \
|
||||
'huggingface_hub>=0.34' tqdm packaging psutil triton Pillow
|
||||
# Editable-install both repos so the test imports the
|
||||
# checkouts (not whatever stale PyPI version pip resolved).
|
||||
pip install --no-deps -e "$RUNNER_TEMP/unsloth-zoo"
|
||||
pip install --no-deps -e ./unsloth
|
||||
- name: Run vllm_compat zoo-imports tests under spoof
|
||||
env:
|
||||
UNSLOTH_IS_PRESENT: '1'
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
|
||||
run: |
|
||||
cd unsloth
|
||||
# tests/vllm_compat/test_unsloth_zoo_imports.py: narrow vllm/grpo
|
||||
# import gates (5 tests).
|
||||
# tests/vllm_compat/test_extended_module_imports.py: full sweep
|
||||
# of unsloth_zoo + unsloth.models.* modules + RL dispatch
|
||||
# table population + FastModel API surface under spoof
|
||||
# (~30 tests). Catches transformers / peft / bnb symbol pin
|
||||
# drift at module-top BEFORE any runtime call.
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/vllm_compat/test_unsloth_zoo_imports.py \
|
||||
tests/vllm_compat/test_extended_module_imports.py \
|
||||
-v --tb=short
|
||||
|
||||
# Daily-only: same suites but with --strict on importable upstream
|
||||
# tags. Schedule-only so PR jobs stay fast; cron tolerates a flake.
|
||||
daily-fresh-fetch:
|
||||
name: daily fresh-fetch sweep (cron only)
|
||||
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install pytest
|
||||
run: pip install 'pytest>=8'
|
||||
- name: Run all version-compat suites in one process (no cache)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/vllm_compat/test_vllm_pinned_symbols.py \
|
||||
tests/version_compat/ \
|
||||
-v --tb=short
|
||||
11
.github/workflows/wheel-smoke.yml
vendored
|
|
@ -32,21 +32,24 @@ concurrency:
|
|||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
wheel:
|
||||
name: Wheel build + content sanity + import smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
|
@ -117,7 +120,7 @@ jobs:
|
|||
|
||||
- name: Upload wheel on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: unsloth-wheel
|
||||
path: dist/
|
||||
|
|
|
|||
2
.gitignore
vendored
|
|
@ -3,6 +3,8 @@ __pycache__/
|
|||
*.py[cod]
|
||||
*.class
|
||||
unsloth_compiled_cache/
|
||||
# Notebook-validator runtime PyPI metadata cache (CI repopulates).
|
||||
scripts/data/pypi_cache/
|
||||
# ML artifacts (large files)
|
||||
feature/
|
||||
outputs/
|
||||
|
|
|
|||
183
.semgrep/unsloth-rules.yml
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Custom Semgrep rules for unsloth + studio backend. The off-the-shelf
|
||||
# rule packs (p/python, p/javascript, p/supply-chain, p/security-audit)
|
||||
# wired into the security-audit workflow already cover the common
|
||||
# patterns. These rules add catches for the *specific* shape of recent
|
||||
# CVEs in the broader Python ML / dev-tools stack -- so if we ever
|
||||
# introduce a similar bug ourselves, CI lights up.
|
||||
#
|
||||
# Run locally:
|
||||
# pip install 'semgrep>=1.95'
|
||||
# semgrep --config .semgrep/unsloth-rules.yml studio/backend unsloth scripts
|
||||
#
|
||||
# Wired into CI via .github/workflows/security-audit.yml's Semgrep step.
|
||||
|
||||
rules:
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# langchain-core CVE-2025-68664 shape:
|
||||
# `dumps()` / `dumpd()` over a user-controlled dict that may carry
|
||||
# the `lc` marker key -> deserialization injection on the round
|
||||
# trip. Catch any json.dumps / pickle.dumps / yaml.dump on data
|
||||
# that flowed through a Request/WebSocket payload.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
- id: unsloth-deserialize-roundtrip
|
||||
message: >-
|
||||
Serializing user-controlled data with langchain-style `dumps`
|
||||
can re-instantiate arbitrary classes when deserialized. See
|
||||
langchain-core CVE-2025-68664. Sanitize / strip `lc` marker keys
|
||||
before dumping, or use a strict schema (Pydantic) instead.
|
||||
severity: WARNING
|
||||
languages: [python]
|
||||
patterns:
|
||||
- pattern-either:
|
||||
- pattern: langchain_core.load.dumps($DATA, ...)
|
||||
- pattern: langchain_core.load.dumpd($DATA, ...)
|
||||
- pattern: dumps($DATA)
|
||||
- pattern: dumpd($DATA)
|
||||
- metavariable-pattern:
|
||||
metavariable: $DATA
|
||||
patterns:
|
||||
- pattern-either:
|
||||
- pattern: request.$F
|
||||
- pattern: payload
|
||||
- pattern: body
|
||||
- pattern: data
|
||||
- pattern: input
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# n8n CVE-2025-68668 shape:
|
||||
# `_pyodide._base.eval_code(...)` or any private/underscore call
|
||||
# into pyodide internals that escapes the public sandbox API.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
- id: unsloth-pyodide-private-eval
|
||||
message: >-
|
||||
Calling `_pyodide._base.eval_code` (or any `_pyodide.<private>`)
|
||||
bypasses the public Pyodide sandbox -- this is how n8n
|
||||
CVE-2025-68668 (CVSS 9.9) escaped the Code Node's blocklist.
|
||||
Use the documented sandbox API (`pyodide.runPython`) and rely
|
||||
on web-worker isolation for untrusted input.
|
||||
severity: ERROR
|
||||
languages: [python, javascript, typescript]
|
||||
patterns:
|
||||
- pattern-either:
|
||||
- pattern: _pyodide._base.eval_code(...)
|
||||
- pattern: $X._pyodide.$Y(...)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# marimo CVE-2026-39987 shape:
|
||||
# FastAPI / Starlette WebSocket route that accepts connections
|
||||
# without checking auth -- in marimo this dropped a PTY shell to
|
||||
# any unauthenticated attacker.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
- id: unsloth-websocket-no-auth
|
||||
message: >-
|
||||
WebSocket route accepts connections without an auth check.
|
||||
marimo CVE-2026-39987 was a pre-auth WebSocket on
|
||||
`/terminal/ws` that handed a full PTY shell to any
|
||||
unauthenticated peer. Add a Depends(get_current_user) /
|
||||
`await websocket.headers.get("authorization")` gate before
|
||||
`await websocket.accept()`.
|
||||
severity: WARNING
|
||||
languages: [python]
|
||||
patterns:
|
||||
- pattern: |
|
||||
@$APP.websocket("...")
|
||||
async def $F(websocket: WebSocket, ...):
|
||||
...
|
||||
await websocket.accept()
|
||||
...
|
||||
- pattern-not-inside: |
|
||||
@$APP.websocket("...")
|
||||
async def $F(websocket: WebSocket, ..., $USER = Depends(...)):
|
||||
...
|
||||
- pattern-not-inside: |
|
||||
@$APP.websocket("...")
|
||||
async def $F(websocket: WebSocket, ...):
|
||||
...
|
||||
if not $AUTH:
|
||||
...
|
||||
await websocket.accept()
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# litellm 1.82.7 shape:
|
||||
# `subprocess.Popen` of a child Python interpreter that reads
|
||||
# stdin from a network response (the C2-fetch-then-exec dropper
|
||||
# pattern). Catches both `Popen([sys.executable, ...], stdin=...)`
|
||||
# and `Popen("python ...", stdin=...)` variants.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
- id: unsloth-popen-network-stdin
|
||||
message: >-
|
||||
Spawning a Python interpreter that reads its program from a
|
||||
network call is the canonical fetch-and-exec dropper (litellm
|
||||
1.82.7 used this exact shape). Almost never legitimate inside a
|
||||
package's import path.
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
pattern-either:
|
||||
- pattern: |
|
||||
subprocess.Popen([..., $PY, ...], stdin=$NET, ...)
|
||||
- pattern: |
|
||||
subprocess.run([..., $PY, ...], input=$NET, ...)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Shai-Hulud / ForceMemo shape:
|
||||
# programmatic write of a `.github/workflows/*.yml` file from
|
||||
# inside our own Python source. We never write workflows
|
||||
# programmatically; if a contributor ever does, they're probably
|
||||
# re-implementing the worm pattern.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
- id: unsloth-write-github-workflow
|
||||
message: >-
|
||||
Code that programmatically writes into `.github/workflows/`
|
||||
from within unsloth itself is the Shai-Hulud / ForceMemo
|
||||
self-propagation pattern. If you legitimately need a workflow
|
||||
template, ship it under examples/ or templates/ instead.
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
patterns:
|
||||
- pattern-either:
|
||||
- pattern: open("$P", ...)
|
||||
- pattern: Path("$P").write_text(...)
|
||||
- pattern: open("$P", "w", ...)
|
||||
- metavariable-regex:
|
||||
metavariable: $P
|
||||
regex: \.github/workflows/.*\.ya?ml
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Pickle-from-network shape: classic deserialization sink that
|
||||
# several recent ML pipeline CVEs hit (mlflow, pyzmq, ray serve).
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
- id: unsloth-pickle-from-network
|
||||
message: >-
|
||||
`pickle.loads` on bytes that flowed from a network response is
|
||||
arbitrary code execution. Use `safetensors` or a strict
|
||||
schema (Pydantic / msgspec) instead. ML frameworks have shipped
|
||||
multiple CVEs of this exact shape (mlflow, ray serve, pyzmq).
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
pattern-either:
|
||||
- pattern: pickle.loads($X.content)
|
||||
- pattern: pickle.loads($X.text.encode(...))
|
||||
- pattern: pickle.loads(requests.get(...).content)
|
||||
- pattern: pickle.load(urllib.request.urlopen(...))
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Subprocess shell=True with f-string / format / concat -- command
|
||||
# injection if any interpolated value comes from user input.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
- id: unsloth-shell-true-interpolation
|
||||
message: >-
|
||||
`subprocess` call with `shell=True` and an interpolated command
|
||||
string is command injection if any input is user-controlled.
|
||||
Pass argv list instead, or use shlex.quote on each part.
|
||||
severity: WARNING
|
||||
languages: [python]
|
||||
pattern-either:
|
||||
- pattern: subprocess.run(f"...", shell=True, ...)
|
||||
- pattern: subprocess.Popen(f"...", shell=True, ...)
|
||||
- pattern: subprocess.call(f"...", shell=True, ...)
|
||||
- pattern: os.system(f"...")
|
||||
- pattern: subprocess.run("..." + $X, shell=True, ...)
|
||||
- pattern: subprocess.run("...{}...".format(...), shell=True, ...)
|
||||
1142
scripts/data/colab_apt_list.gpu.txt
Normal file
9
scripts/data/colab_os_info.gpu.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Do not modify this file directly; it is generated by extract_colabx_testing_tarballs.sh via
|
||||
# $ (lsb_release -ds;python --version;) > os-info-gpu.txt
|
||||
# Be aware that this list does not necessarily reflect the current state of the
|
||||
# staging or production container, but rather the state as of the most recent
|
||||
# submitted CL where extract_colabx_testing_tarballs.sh was run.
|
||||
Ubuntu 22.04.5 LTS
|
||||
Python 3.12.13
|
||||
R version 4.5.3 (2026-03-11) -- "Reassured Reassurer"
|
||||
julia version 1.12.6
|
||||
731
scripts/data/colab_pip_freeze.gpu.txt
Normal file
|
|
@ -0,0 +1,731 @@
|
|||
# Do not modify this file directly; it is generated by extract_colabx_testing_tarballs.sh via
|
||||
# $ python3 -m pip freeze
|
||||
# Be aware that this list does not necessarily reflect the current state of the
|
||||
# staging or production container, but rather the state as of the most recent
|
||||
# submitted CL where extract_colabx_testing_tarballs.sh was run.
|
||||
absl-py==1.4.0
|
||||
accelerate==1.13.0
|
||||
access==1.1.10.post3
|
||||
affine==2.4.0
|
||||
aiofiles==24.1.0
|
||||
aiohappyeyeballs==2.6.1
|
||||
aiohttp==3.13.5
|
||||
aiosignal==1.4.0
|
||||
aiosqlite==0.22.1
|
||||
alabaster==1.0.0
|
||||
albucore==0.0.24
|
||||
albumentations==2.0.8
|
||||
ale-py==0.11.2
|
||||
alembic==1.18.4
|
||||
altair==5.5.0
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
antlr4-python3-runtime==4.9.3
|
||||
anyio==4.13.0
|
||||
anywidget==0.9.21
|
||||
apsw==3.53.0.0
|
||||
apswutils==0.1.2
|
||||
argon2-cffi==25.1.0
|
||||
argon2-cffi-bindings==25.1.0
|
||||
array_record==0.8.3
|
||||
arrow==1.4.0
|
||||
arviz==0.22.0
|
||||
astropy==7.2.0
|
||||
astropy-iers-data==0.2026.4.20.0.58.15
|
||||
astunparse==1.6.3
|
||||
atpublic==5.1
|
||||
attrs==26.1.0
|
||||
audioread==3.1.0
|
||||
Authlib==1.6.11
|
||||
autograd==1.8.0
|
||||
babel==2.18.0
|
||||
backcall==0.2.0
|
||||
beartype==0.22.9
|
||||
beautifulsoup4==4.13.5
|
||||
betterproto==2.0.0b6
|
||||
bigframes==2.39.0
|
||||
bigquery-magics==0.14.0
|
||||
bleach==6.3.0
|
||||
blinker==1.9.0
|
||||
blis==1.3.3
|
||||
blobfile==3.2.0
|
||||
blosc2==4.1.2
|
||||
bokeh==3.8.2
|
||||
Bottleneck==1.4.2
|
||||
bqplot==0.12.45
|
||||
branca==0.8.2
|
||||
brotli==1.2.0
|
||||
CacheControl==0.14.4
|
||||
cachetools==6.2.6
|
||||
catalogue==2.0.10
|
||||
certifi==2026.4.22
|
||||
cffi==2.0.0
|
||||
chardet==5.2.0
|
||||
charset-normalizer==3.4.7
|
||||
clarabel==0.11.1
|
||||
click==8.3.3
|
||||
click-plugins==1.1.1.2
|
||||
cligj==0.7.2
|
||||
cloudpathlib==0.23.0
|
||||
cloudpickle==3.1.2
|
||||
cmake==3.31.10
|
||||
cmdstanpy==1.3.0
|
||||
colorcet==3.1.0
|
||||
colorlover==0.3.0
|
||||
community==1.0.0b1
|
||||
confection==1.3.3
|
||||
cons==0.4.7
|
||||
contourpy==1.3.3
|
||||
cramjam==2.11.0
|
||||
cryptography==43.0.3
|
||||
cucim-cu12 @ https://pypi.nvidia.com/cucim-cu12/cucim_cu12-26.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
|
||||
cuda-bindings==12.9.4
|
||||
cuda-core==0.3.2
|
||||
cuda-pathfinder==1.5.3
|
||||
cuda-python==12.9.4
|
||||
cuda-toolkit==12.8.1
|
||||
cudf-cu12==26.2.1
|
||||
cudf-polars-cu12==26.2.1
|
||||
cufflinks==0.17.3
|
||||
cuml-cu12==26.2.0
|
||||
cupy-cuda12x==14.0.1
|
||||
curl_cffi==0.15.0
|
||||
cuvs-cu12 @ https://pypi.nvidia.com/cuvs-cu12/cuvs_cu12-26.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
|
||||
cvxopt==1.3.2
|
||||
cvxpy==1.6.7
|
||||
cycler==0.12.1
|
||||
cyipopt==1.5.0
|
||||
cymem==2.0.13
|
||||
Cython==3.0.12
|
||||
dask==2026.1.1
|
||||
dask-cuda==26.2.0
|
||||
dask-cudf-cu12==26.2.1
|
||||
dataproc-spark-connect==1.1.0
|
||||
datasets==4.0.0
|
||||
db-dtypes==1.5.1
|
||||
dbus-python==1.2.18
|
||||
debugpy==1.8.15
|
||||
decorator==4.4.2
|
||||
defusedxml==0.7.1
|
||||
deprecation==2.1.0
|
||||
diffusers==0.37.1
|
||||
dill==0.3.8
|
||||
distributed==2026.1.1
|
||||
distributed-ucxx-cu12==0.48.0
|
||||
distro==1.9.0
|
||||
dlib==19.24.6
|
||||
dm-tree==0.1.10
|
||||
docstring_parser==0.18.0
|
||||
docutils==0.21.2
|
||||
dopamine_rl==4.1.2
|
||||
duckdb==1.3.2
|
||||
earthengine-api==1.7.22
|
||||
easydict==1.13
|
||||
editdistance==0.8.1
|
||||
eerepr==0.1.2
|
||||
einops==0.8.2
|
||||
en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl#sha256=1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85
|
||||
entrypoints==0.4
|
||||
esda==2.9.0
|
||||
et_xmlfile==2.0.0
|
||||
etils==1.14.0
|
||||
etuples==0.3.10
|
||||
Farama-Notifications==0.0.4
|
||||
fastai==2.8.7
|
||||
fastapi==0.136.1
|
||||
fastcore==1.12.42
|
||||
fastdownload==0.0.7
|
||||
fastjsonschema==2.21.2
|
||||
fastlite==0.2.4
|
||||
fastprogress==1.1.5
|
||||
fasttransform==0.0.2
|
||||
ffmpy==1.0.0
|
||||
filelock==3.29.0
|
||||
fiona==1.10.1
|
||||
firebase-admin==6.9.0
|
||||
Flask==3.1.3
|
||||
flatbuffers==25.12.19
|
||||
flax==0.11.2
|
||||
folium==0.20.0
|
||||
fonttools==4.62.1
|
||||
fqdn==1.5.1
|
||||
frozendict==2.4.7
|
||||
frozenlist==1.8.0
|
||||
fsspec==2025.3.0
|
||||
future==1.0.0
|
||||
gast==0.7.0
|
||||
gcsfs==2025.3.0
|
||||
GDAL==3.8.4
|
||||
gdown==5.2.2
|
||||
geemap==0.37.2
|
||||
geocoder==1.38.1
|
||||
geographiclib==2.1
|
||||
geopandas==1.1.3
|
||||
geopy==2.4.1
|
||||
giddy==2.3.6
|
||||
gin-config==0.5.0
|
||||
gitdb==4.0.12
|
||||
GitPython==3.1.47
|
||||
glob2==0.7
|
||||
google==3.0.0
|
||||
google-adk==1.29.0
|
||||
google-ai-generativelanguage==0.6.15
|
||||
google-api-core==2.30.3
|
||||
google-api-python-client==2.194.0
|
||||
google-auth==2.47.0
|
||||
google-auth-httplib2==0.3.1
|
||||
google-auth-oauthlib==1.3.1
|
||||
google-cloud-aiplatform==1.148.1
|
||||
google-cloud-appengine-logging==1.9.0
|
||||
google-cloud-audit-log==0.5.0
|
||||
google-cloud-bigquery==3.41.0
|
||||
google-cloud-bigquery-connection==1.21.0
|
||||
google-cloud-bigquery-storage==2.37.0
|
||||
google-cloud-bigtable==2.36.0
|
||||
google-cloud-core==2.5.1
|
||||
google-cloud-dataplex==2.18.0
|
||||
google-cloud-dataproc==5.27.0
|
||||
google-cloud-datastore==2.24.0
|
||||
google-cloud-discoveryengine==0.13.12
|
||||
google-cloud-firestore==2.27.0
|
||||
google-cloud-functions==1.23.0
|
||||
google-cloud-iam==2.22.0
|
||||
google-cloud-language==2.20.0
|
||||
google-cloud-logging==3.15.0
|
||||
google-cloud-monitoring==2.30.0
|
||||
google-cloud-pubsub==2.37.0
|
||||
google-cloud-resource-manager==1.17.0
|
||||
google-cloud-secret-manager==2.27.0
|
||||
google-cloud-spanner==3.65.0
|
||||
google-cloud-speech==2.38.0
|
||||
google-cloud-storage==3.10.1
|
||||
google-cloud-trace==1.19.0
|
||||
google-cloud-translate==3.26.0
|
||||
google-colab @ file:///colabtools/dist/google_colab-1.0.0.tar.gz
|
||||
google-crc32c==1.8.0
|
||||
google-genai==1.68.0
|
||||
google-generativeai==0.8.6
|
||||
google-pasta==0.2.0
|
||||
google-resumable-media==2.8.2
|
||||
googleapis-common-protos==1.74.0
|
||||
googledrivedownloader==1.1.0
|
||||
gradio==5.50.0
|
||||
gradio_client==1.14.0
|
||||
grain==0.2.16
|
||||
graphviz==0.21
|
||||
greenlet==3.4.0
|
||||
groovy==0.1.2
|
||||
grpc-google-iam-v1==0.14.4
|
||||
grpc-interceptor==0.15.4
|
||||
grpcio==1.80.0
|
||||
grpcio-status==1.71.2
|
||||
grpclib==0.4.9
|
||||
gspread==6.2.1
|
||||
gspread-dataframe==4.0.0
|
||||
gym==0.25.2
|
||||
gym-notices==0.1.0
|
||||
gymnasium==1.3.0
|
||||
h11==0.16.0
|
||||
h2==4.3.0
|
||||
h5netcdf==1.8.1
|
||||
h5py==3.16.0
|
||||
hdbscan==0.8.42
|
||||
hf-xet==1.4.3
|
||||
highspy==1.14.0
|
||||
holidays==0.95
|
||||
holoviews==1.22.1
|
||||
hpack==4.1.0
|
||||
html5lib==1.1
|
||||
httpcore==1.0.9
|
||||
httpimport==1.4.1
|
||||
httplib2==0.31.2
|
||||
httptools==0.7.1
|
||||
httpx==0.28.1
|
||||
httpx-sse==0.4.3
|
||||
huggingface_hub==1.11.0
|
||||
humanize==4.15.0
|
||||
hyperframe==6.1.0
|
||||
hyperopt==0.2.7
|
||||
ibis-framework==9.5.0
|
||||
idna==3.13
|
||||
ImageIO==2.37.3
|
||||
imageio-ffmpeg==0.6.0
|
||||
imagesize==2.0.0
|
||||
imbalanced-learn==0.14.1
|
||||
immutabledict==4.3.1
|
||||
importlib_metadata==8.7.1
|
||||
importlib_resources==7.1.0
|
||||
imutils==0.5.4
|
||||
inequality==1.1.2
|
||||
inflect==7.5.0
|
||||
iniconfig==2.3.0
|
||||
intel-cmplr-lib-ur==2025.3.3
|
||||
intel-openmp==2025.3.3
|
||||
ipyevents==2.0.4
|
||||
ipyfilechooser==0.6.0
|
||||
ipykernel==6.17.1
|
||||
ipyleaflet==0.20.0
|
||||
ipyparallel==8.8.0
|
||||
ipython==7.34.0
|
||||
ipython-genutils==0.2.0
|
||||
ipython-sql==0.5.0
|
||||
ipywidgets==7.7.1
|
||||
isoduration==20.11.0
|
||||
itsdangerous==2.2.0
|
||||
jaraco.classes==3.4.0
|
||||
jaraco.context==6.1.2
|
||||
jaraco.functools==4.4.0
|
||||
jax==0.7.2
|
||||
jax-cuda12-pjrt==0.7.2
|
||||
jax-cuda12-plugin==0.7.2
|
||||
jaxlib==0.7.2
|
||||
jeepney==0.9.0
|
||||
jieba==0.42.1
|
||||
Jinja2==3.1.6
|
||||
jiter==0.14.0
|
||||
joblib==1.5.3
|
||||
jsonpatch==1.33
|
||||
jsonpickle==4.1.1
|
||||
jsonpointer==3.1.1
|
||||
jsonschema==4.26.0
|
||||
jsonschema-specifications==2025.9.1
|
||||
jupyter-console==6.6.3
|
||||
jupyter-events==0.12.1
|
||||
jupyter-leaflet==0.20.0
|
||||
jupyter_client==7.4.9
|
||||
jupyter_core==5.9.1
|
||||
jupyter_kernel_gateway @ git+https://github.com/googlecolab/kernel_gateway@b134e9945df25c2dcb98ade9129399be10788671
|
||||
jupyter_server==2.14.0
|
||||
jupyter_server_terminals==0.5.4
|
||||
jupyterlab_pygments==0.3.0
|
||||
jupyterlab_widgets==3.0.16
|
||||
jupytext==1.19.1
|
||||
kaggle==2.0.2
|
||||
kagglehub==1.0.0
|
||||
kagglesdk==0.1.20
|
||||
keras==3.13.2
|
||||
keras-hub==0.26.0
|
||||
keras-nlp==0.26.0
|
||||
keyring==25.7.0
|
||||
keyrings.google-artifactregistry-auth==1.1.2
|
||||
kiwisolver==1.5.0
|
||||
langchain==1.2.15
|
||||
langchain-core==1.3.1
|
||||
langgraph==1.1.9
|
||||
langgraph-checkpoint==4.0.2
|
||||
langgraph-prebuilt==1.0.10
|
||||
langgraph-sdk==0.3.13
|
||||
langsmith==0.7.34
|
||||
lark==1.3.1
|
||||
launchpadlib==1.10.16
|
||||
lazr.restfulclient==0.14.4
|
||||
lazr.uri==1.0.6
|
||||
lazy-loader==0.5
|
||||
libclang==18.1.1
|
||||
libcudf-cu12==26.2.1
|
||||
libcugraph-cu12==26.2.0
|
||||
libcuml-cu12==26.2.0
|
||||
libcuvs-cu12==26.2.0
|
||||
libkvikio-cu12==26.2.0
|
||||
libpysal==4.14.1
|
||||
libraft-cu12==26.2.0
|
||||
librmm-cu12==26.2.0
|
||||
librosa==0.11.0
|
||||
libucx-cu12==1.19.0
|
||||
libucxx-cu12==0.48.0
|
||||
lightgbm==4.6.0
|
||||
linkify-it-py==2.1.0
|
||||
llvmlite==0.43.0
|
||||
locket==1.0.0
|
||||
logical-unification==0.4.7
|
||||
lxml==6.1.0
|
||||
Mako==1.3.11
|
||||
mapclassify==2.10.0
|
||||
Markdown==3.10.2
|
||||
markdown-it-py==4.0.0
|
||||
MarkupSafe==3.0.3
|
||||
matplotlib==3.10.0
|
||||
matplotlib-inline==0.2.1
|
||||
matplotlib-venn==1.1.2
|
||||
mcp==1.27.0
|
||||
mdit-py-plugins==0.5.0
|
||||
mdurl==0.1.2
|
||||
mgwr==2.2.1
|
||||
miniKanren==1.0.5
|
||||
missingno==0.5.2
|
||||
mistune==3.2.0
|
||||
mizani==0.13.5
|
||||
mkl==2025.3.1
|
||||
ml_dtypes==0.5.4
|
||||
mlxtend==0.23.4
|
||||
mmh3==5.2.1
|
||||
momepy==0.11.0
|
||||
more-itertools==10.8.0
|
||||
moviepy==1.0.3
|
||||
mpmath==1.3.0
|
||||
msgpack==1.1.2
|
||||
multidict==6.7.1
|
||||
multipledispatch==1.0.0
|
||||
multiprocess==0.70.16
|
||||
multitasking==0.0.13
|
||||
murmurhash==1.0.15
|
||||
music21==9.9.1
|
||||
namex==0.1.0
|
||||
narwhals==2.20.0
|
||||
natsort==8.4.0
|
||||
nbclassic==1.3.3
|
||||
nbclient==0.10.4
|
||||
nbconvert==7.17.1
|
||||
nbformat==5.10.4
|
||||
ndindex==1.10.1
|
||||
nest-asyncio==1.6.0
|
||||
networkx==3.6.1
|
||||
nibabel==5.4.2
|
||||
nltk==3.9.1
|
||||
notebook==6.5.7
|
||||
notebook_shim==0.2.4
|
||||
numba==0.60.0
|
||||
numba-cuda==0.22.2
|
||||
numexpr==2.14.1
|
||||
numpy==2.0.2
|
||||
nvidia-cublas-cu12==12.8.4.1
|
||||
nvidia-cuda-cccl-cu12==12.9.27
|
||||
nvidia-cuda-cupti-cu12==12.8.90
|
||||
nvidia-cuda-nvcc-cu12==12.8.93
|
||||
nvidia-cuda-nvrtc-cu12==12.8.93
|
||||
nvidia-cuda-runtime-cu12==12.8.90
|
||||
nvidia-cudnn-cu12==9.10.2.21
|
||||
nvidia-cufft-cu12==11.3.3.83
|
||||
nvidia-cufile-cu12==1.13.1.3
|
||||
nvidia-curand-cu12==10.3.9.90
|
||||
nvidia-cusolver-cu12==11.7.3.90
|
||||
nvidia-cusparse-cu12==12.5.8.93
|
||||
nvidia-cusparselt-cu12==0.7.1
|
||||
nvidia-libnvcomp-cu12==5.1.0.21
|
||||
nvidia-ml-py==13.595.45
|
||||
nvidia-nccl-cu12==2.27.5
|
||||
nvidia-nvimgcodec-cu12==0.7.0.11
|
||||
nvidia-nvjitlink-cu12==12.8.93
|
||||
nvidia-nvshmem-cu12==3.4.5
|
||||
nvidia-nvtx-cu12==12.8.90
|
||||
nvtx==0.2.15
|
||||
nx-cugraph-cu12 @ https://pypi.nvidia.com/nx-cugraph-cu12/nx_cugraph_cu12-26.2.0-py3-none-any.whl
|
||||
oauth2client==4.1.3
|
||||
oauthlib==3.3.1
|
||||
omegaconf==2.3.0
|
||||
onemkl-license==2025.3.1
|
||||
openai==2.32.0
|
||||
opencv-contrib-python==4.13.0.92
|
||||
opencv-python==4.13.0.92
|
||||
opencv-python-headless==4.13.0.92
|
||||
openpyxl==3.1.5
|
||||
opentelemetry-api==1.38.0
|
||||
opentelemetry-exporter-gcp-logging==1.11.0a0
|
||||
opentelemetry-exporter-gcp-monitoring==1.11.0a0
|
||||
opentelemetry-exporter-gcp-trace==1.11.0
|
||||
opentelemetry-exporter-otlp-proto-common==1.38.0
|
||||
opentelemetry-exporter-otlp-proto-http==1.38.0
|
||||
opentelemetry-proto==1.38.0
|
||||
opentelemetry-resourcedetector-gcp==1.11.0a0
|
||||
opentelemetry-sdk==1.38.0
|
||||
opentelemetry-semantic-conventions==0.59b0
|
||||
opt_einsum==3.4.0
|
||||
optax==0.2.8
|
||||
optree==0.19.0
|
||||
orbax-checkpoint==0.11.36
|
||||
orjson==3.11.8
|
||||
ormsgpack==1.12.2
|
||||
osqp==1.1.1
|
||||
overrides==7.7.0
|
||||
packaging==26.1
|
||||
pandas==2.2.2
|
||||
pandas-datareader==0.10.0
|
||||
pandas-gbq==0.30.0
|
||||
pandas-stubs==2.2.2.240909
|
||||
pandocfilters==1.5.1
|
||||
panel==1.8.10
|
||||
param==2.3.3
|
||||
parso==0.8.6
|
||||
parsy==2.2
|
||||
partd==1.4.2
|
||||
patsy==1.0.2
|
||||
peewee==4.0.5
|
||||
peft==0.19.1
|
||||
pexpect==4.9.0
|
||||
pickleshare==0.7.5
|
||||
pillow==11.3.0
|
||||
pip==24.1.2
|
||||
platformdirs==4.9.6
|
||||
plotly==5.24.1
|
||||
plotnine==0.14.5
|
||||
pluggy==1.6.0
|
||||
plum-dispatch==2.8.0
|
||||
pointpats==2.5.5
|
||||
polars==1.35.2
|
||||
polars-runtime-32==1.35.2
|
||||
pooch==1.9.0
|
||||
portpicker==1.5.2
|
||||
preshed==3.0.13
|
||||
prettytable==3.17.0
|
||||
proglog==0.1.12
|
||||
progressbar2==4.5.0
|
||||
prometheus_client==0.25.0
|
||||
promise==2.3
|
||||
prompt_toolkit==3.0.52
|
||||
propcache==0.4.1
|
||||
prophet==1.3.0
|
||||
proto-plus==1.27.2
|
||||
protobuf==5.29.6
|
||||
psutil==5.9.5
|
||||
psycopg2==2.9.12
|
||||
psygnal==0.15.1
|
||||
ptyprocess==0.7.0
|
||||
PuLP==3.3.0
|
||||
py-cpuinfo==9.0.0
|
||||
py4j==0.10.9.9
|
||||
pyarrow==18.1.0
|
||||
pyasn1==0.6.3
|
||||
pyasn1_modules==0.4.2
|
||||
pycairo==1.29.0
|
||||
pycocotools==2.0.11
|
||||
pycparser==3.0
|
||||
pycryptodomex==3.23.0
|
||||
pydantic==2.12.3
|
||||
pydantic-settings==2.14.0
|
||||
pydantic_core==2.41.4
|
||||
pydata-google-auth==1.9.1
|
||||
pydot==4.0.1
|
||||
pydotplus==2.0.2
|
||||
PyDrive2==1.21.3
|
||||
pydub==0.25.1
|
||||
pyerfa==2.0.1.5
|
||||
pygame==2.6.1
|
||||
pygit2==1.19.2
|
||||
Pygments==2.20.0
|
||||
PyGObject==3.48.2
|
||||
pyiceberg==0.11.1
|
||||
PyJWT==2.12.1
|
||||
pylibcudf-cu12==26.2.1
|
||||
pylibcugraph-cu12==26.2.0
|
||||
pylibraft-cu12==26.2.0
|
||||
pymc==5.28.4
|
||||
pynndescent==0.6.0
|
||||
pyogrio==0.12.1
|
||||
pyomo==6.10.0
|
||||
PyOpenGL==3.1.10
|
||||
pyOpenSSL==24.2.1
|
||||
pyparsing==3.3.2
|
||||
pyperclip==1.11.0
|
||||
pyproj==3.7.2
|
||||
pyroaring==1.0.4
|
||||
pysal==25.7
|
||||
pyshp==3.0.3
|
||||
PySocks==1.7.1
|
||||
pyspark==4.0.2
|
||||
pytensor==2.38.2
|
||||
pytest==8.4.2
|
||||
python-apt==0.0.0
|
||||
python-box==7.4.1
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.2.2
|
||||
python-fasthtml==0.12.50
|
||||
python-json-logger==4.1.0
|
||||
python-louvain==0.16
|
||||
python-multipart==0.0.26
|
||||
python-slugify==8.0.4
|
||||
python-snappy==0.7.3
|
||||
python-utils==3.9.1
|
||||
pytz==2025.2
|
||||
pyviz_comms==3.0.6
|
||||
PyWavelets==1.9.0
|
||||
PyYAML==6.0.3
|
||||
pyzmq==26.2.1
|
||||
quantecon==0.11.2
|
||||
raft-dask-cu12==26.2.0
|
||||
rapids-dask-dependency==26.2.0
|
||||
rapids-logger==0.2.3
|
||||
rasterio==1.5.0
|
||||
rasterstats==0.20.0
|
||||
ratelim==0.1.6
|
||||
referencing==0.37.0
|
||||
regex==2025.11.3
|
||||
requests==2.32.4
|
||||
requests-oauthlib==2.0.0
|
||||
requests-toolbelt==1.0.0
|
||||
requirements-parser==0.9.0
|
||||
rfc3339-validator==0.1.4
|
||||
rfc3986-validator==0.1.1
|
||||
rfc3987-syntax==1.1.0
|
||||
rich==13.9.4
|
||||
rmm-cu12==26.2.0
|
||||
roman-numerals==4.1.0
|
||||
roman-numerals-py==4.1.0
|
||||
rpds-py==0.30.0
|
||||
rpy2==3.5.17
|
||||
rsa==4.9.1
|
||||
rtree==1.4.1
|
||||
ruff==0.15.11
|
||||
safehttpx==0.1.7
|
||||
safetensors==0.7.0
|
||||
scikit-image==0.25.2
|
||||
scikit-learn==1.6.1
|
||||
scipy==1.16.3
|
||||
scooby==0.11.2
|
||||
scs==3.2.11
|
||||
seaborn==0.13.2
|
||||
SecretStorage==3.5.0
|
||||
segregation==2.5.4
|
||||
semantic-version==2.10.0
|
||||
Send2Trash==2.1.0
|
||||
sentence-transformers==5.4.1
|
||||
sentencepiece==0.2.1
|
||||
sentry-sdk==2.58.0
|
||||
setuptools==75.2.0
|
||||
shap==0.51.0
|
||||
shapely==2.1.2
|
||||
shellingham==1.5.4
|
||||
simple-parsing==0.1.8
|
||||
simplejson==4.1.0
|
||||
simsimd==6.5.16
|
||||
six==1.17.0
|
||||
sklearn-compat==0.1.5
|
||||
sklearn-pandas==2.2.0
|
||||
slicer==0.0.8
|
||||
smart_open==7.6.0
|
||||
smmap==5.0.3
|
||||
sniffio==1.3.1
|
||||
snowballstemmer==3.0.1
|
||||
sortedcontainers==2.4.0
|
||||
soundfile==0.13.1
|
||||
soupsieve==2.8.3
|
||||
soxr==1.0.0
|
||||
spacy==3.8.14
|
||||
spacy-legacy==3.0.12
|
||||
spacy-loggers==1.0.5
|
||||
spaghetti==1.7.6
|
||||
spanner-graph-notebook==1.1.10
|
||||
spglm==1.1.0
|
||||
Sphinx==8.2.3
|
||||
sphinxcontrib-applehelp==2.0.0
|
||||
sphinxcontrib-devhelp==2.0.0
|
||||
sphinxcontrib-htmlhelp==2.1.0
|
||||
sphinxcontrib-jsmath==1.0.1
|
||||
sphinxcontrib-qthelp==2.0.0
|
||||
sphinxcontrib-serializinghtml==2.0.0
|
||||
spint==1.0.7
|
||||
splot==1.1.7
|
||||
spopt==0.7.0
|
||||
spreg==1.9.0
|
||||
SQLAlchemy==2.0.49
|
||||
sqlalchemy-spanner==1.17.3
|
||||
sqlglot==25.20.2
|
||||
sqlparse==0.5.5
|
||||
srsly==2.5.3
|
||||
sse-starlette==3.3.4
|
||||
stanio==0.5.1
|
||||
starlette==0.52.1
|
||||
statsmodels==0.14.6
|
||||
strictyaml==1.7.3
|
||||
stringzilla==4.6.0
|
||||
stumpy==1.13.0
|
||||
sympy==1.14.0
|
||||
tables==3.10.2
|
||||
tabulate==0.9.0
|
||||
tbb==2022.3.1
|
||||
tblib==3.2.2
|
||||
tcmlib==1.4.1
|
||||
tenacity==9.1.4
|
||||
tensorboard==2.20.0
|
||||
tensorboard-data-server==0.7.2
|
||||
tensorflow==2.20.0
|
||||
tensorflow-datasets==4.9.9
|
||||
tensorflow-hub==0.16.1
|
||||
tensorflow-metadata==1.17.3
|
||||
tensorflow-probability==0.25.0
|
||||
tensorflow-text==2.20.1
|
||||
tensorstore==0.1.82
|
||||
termcolor==3.3.0
|
||||
terminado==0.18.1
|
||||
text-unidecode==1.3
|
||||
textblob==0.19.0
|
||||
tf-slim==1.1.0
|
||||
tf_keras==2.20.0
|
||||
thinc==8.3.13
|
||||
threadpoolctl==3.6.0
|
||||
tifffile==2026.4.11
|
||||
tiktoken==0.12.0
|
||||
timm==1.0.26
|
||||
tinycss2==1.4.0
|
||||
tobler==0.14.0
|
||||
tokenizers==0.22.2
|
||||
toml==0.10.2
|
||||
tomlkit==0.13.3
|
||||
toolz==0.12.1
|
||||
torch==2.10.0+cu128
|
||||
torchao==0.10.0
|
||||
torchaudio==2.10.0+cu128
|
||||
torchcodec==0.10.0+cu128
|
||||
torchdata==0.11.0
|
||||
torchsummary==1.5.1
|
||||
torchtune==0.6.1
|
||||
torchvision==0.25.0+cu128
|
||||
tornado==6.5.1
|
||||
tqdm==4.67.3
|
||||
traitlets==5.7.1
|
||||
traittypes==0.2.3
|
||||
transformers==5.0.0
|
||||
treelite==4.7.0
|
||||
treescope==0.1.10
|
||||
triton==3.6.0
|
||||
tsfresh==0.21.1
|
||||
tweepy==4.16.0
|
||||
typeguard==4.5.1
|
||||
typer==0.24.2
|
||||
typer-slim==0.24.0
|
||||
types-pytz==2026.1.1.20260408
|
||||
types-setuptools==82.0.0.20260408
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
tzdata==2026.1
|
||||
tzlocal==5.3.1
|
||||
uc-micro-py==2.0.0
|
||||
ucxx-cu12==0.48.0
|
||||
umap-learn==0.5.12
|
||||
umf==1.0.3
|
||||
uri-template==1.3.0
|
||||
uritemplate==4.2.0
|
||||
urllib3==2.5.0
|
||||
uuid_utils==0.14.1
|
||||
uvicorn==0.46.0
|
||||
uvloop==0.22.1
|
||||
vega-datasets==0.9.0
|
||||
wadllib==1.3.6
|
||||
wandb==0.26.1
|
||||
wasabi==1.1.3
|
||||
watchdog==6.0.0
|
||||
watchfiles==1.1.1
|
||||
wcwidth==0.6.0
|
||||
weasel==1.0.0
|
||||
webcolors==25.10.0
|
||||
webencodings==0.5.1
|
||||
websocket-client==1.9.0
|
||||
websockets==15.0.1
|
||||
Werkzeug==3.1.8
|
||||
wheel==0.47.0
|
||||
widgetsnbextension==3.6.10
|
||||
wordcloud==1.9.6
|
||||
wrapt==2.1.2
|
||||
xarray==2025.12.0
|
||||
xarray-einstats==0.10.0
|
||||
xgboost==3.2.0
|
||||
xlrd==2.0.2
|
||||
xxhash==3.6.0
|
||||
xyzservices==2026.3.0
|
||||
yarl==1.23.0
|
||||
ydf==0.15.0
|
||||
ydf_tf==2.20.0
|
||||
yellowbrick==1.5
|
||||
yfinance==0.2.66
|
||||
zict==3.0.0
|
||||
zipp==3.23.1
|
||||
zstandard==0.25.0
|
||||
36
scripts/data/colab_to_cpu_pin.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"_comment": "Maps Colab GPU runtime pinned wheels to CPU equivalents for ubuntu-latest CI smoke jobs. The Colab GPU image ships +cu128 builds that won't install on a CPU-only runner; this map either rewrites the spec to a CPU wheel from https://download.pytorch.org/whl/cpu or falls back to module-spoof for packages with no CPU build.",
|
||||
"rewrite": {
|
||||
"torch": {
|
||||
"from_local_version": "+cu128",
|
||||
"to_index_url": "https://download.pytorch.org/whl/cpu"
|
||||
},
|
||||
"torchvision": {
|
||||
"from_local_version": "+cu128",
|
||||
"to_index_url": "https://download.pytorch.org/whl/cpu"
|
||||
},
|
||||
"torchaudio": {
|
||||
"from_local_version": "+cu128",
|
||||
"to_index_url": "https://download.pytorch.org/whl/cpu"
|
||||
}
|
||||
},
|
||||
"module_spoof": {
|
||||
"torchcodec": "no CPU wheel published; smoke job sys.modules-stubs torchcodec before importing unsloth"
|
||||
},
|
||||
"skip": [
|
||||
"nvidia-cublas-cu12",
|
||||
"nvidia-cuda-cupti-cu12",
|
||||
"nvidia-cuda-nvrtc-cu12",
|
||||
"nvidia-cuda-runtime-cu12",
|
||||
"nvidia-cudnn-cu12",
|
||||
"nvidia-cufft-cu12",
|
||||
"nvidia-curand-cu12",
|
||||
"nvidia-cusolver-cu12",
|
||||
"nvidia-cusparse-cu12",
|
||||
"nvidia-cusparselt-cu12",
|
||||
"nvidia-nccl-cu12",
|
||||
"nvidia-nvjitlink-cu12",
|
||||
"nvidia-nvtx-cu12",
|
||||
"triton"
|
||||
]
|
||||
}
|
||||
300
scripts/notebook_to_python.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
#!/usr/bin/env python
|
||||
# coding: utf-8
|
||||
"""
|
||||
Convert Jupyter notebooks (.ipynb) to executable Python scripts (.py).
|
||||
|
||||
Converts IPython magics to plain Python:
|
||||
!command -> subprocess.run('command', shell=True)
|
||||
%cd path -> os.chdir('path')
|
||||
%env VAR=value -> os.environ['VAR'] = 'value'
|
||||
%%file filename -> with open('filename', 'w') as f: f.write(...)
|
||||
%%capture -> (skipped)
|
||||
/content/... -> _WORKING_DIR + /...
|
||||
"""
|
||||
|
||||
import nbformat
|
||||
import re
|
||||
import sys
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def needs_fstring(cmd: str) -> bool:
|
||||
"""Check if command has Python variable interpolation like {var_name}."""
|
||||
pattern = r"(?<!\$)\{([a-zA-Z_][a-zA-Z0-9_]*)\}"
|
||||
return bool(re.search(pattern, cmd))
|
||||
|
||||
|
||||
def github_blob_to_raw(url: str) -> str:
|
||||
"""Convert GitHub blob URL to raw URL."""
|
||||
# https://github.com/user/repo/blob/branch/path
|
||||
# -> https://raw.githubusercontent.com/user/repo/branch/path
|
||||
# Compare the parsed host exactly (not as a substring) so a URL
|
||||
# like https://attacker.example.com/github.com/blob/... does NOT
|
||||
# get rewritten to a github raw URL. Closes CodeQL alert
|
||||
# py/incomplete-url-substring-sanitization.
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.netloc != "github.com" or "/blob/" not in parsed.path:
|
||||
return url
|
||||
new_path = parsed.path.replace("/blob/", "/", 1)
|
||||
return urllib.parse.urlunparse(
|
||||
parsed._replace(netloc = "raw.githubusercontent.com", path = new_path)
|
||||
)
|
||||
|
||||
|
||||
def download_notebook(url: str) -> tuple[str, str]:
|
||||
"""Download notebook from URL. Returns (content, filename)."""
|
||||
# Convert blob URL to raw if needed
|
||||
raw_url = github_blob_to_raw(url)
|
||||
|
||||
# Extract filename from URL
|
||||
parsed = urllib.parse.urlparse(raw_url)
|
||||
filename = os.path.basename(urllib.parse.unquote(parsed.path))
|
||||
|
||||
# Download
|
||||
print(f"Downloading {url}...")
|
||||
with urllib.request.urlopen(raw_url, timeout = 60) as response:
|
||||
content = response.read().decode("utf-8")
|
||||
|
||||
return content, filename
|
||||
|
||||
|
||||
def is_url(path: str) -> bool:
|
||||
"""Check if path is a URL."""
|
||||
return path.startswith("http://") or path.startswith("https://")
|
||||
|
||||
|
||||
def replace_colab_paths(source: str) -> str:
|
||||
"""Replace Colab-specific /content/ paths with current working directory."""
|
||||
# Replace /content/ with f-string using _WORKING_DIR
|
||||
source = source.replace('"/content/', 'f"{_WORKING_DIR}/')
|
||||
source = source.replace("'/content/", "f'{_WORKING_DIR}/")
|
||||
return source
|
||||
|
||||
|
||||
def convert_cell_to_python(source: str) -> str:
|
||||
"""Convert a cell's IPython magics to plain Python."""
|
||||
lines = source.split("\n")
|
||||
result = []
|
||||
i = 0
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
indent = line[: len(line) - len(line.lstrip())]
|
||||
|
||||
# Skip %%capture
|
||||
if stripped.startswith("%%capture"):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Handle %%file magic
|
||||
if stripped.startswith("%%file "):
|
||||
filename = stripped[7:].strip()
|
||||
file_lines = []
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
file_lines.append(lines[i])
|
||||
i += 1
|
||||
file_content = "\n".join(file_lines)
|
||||
file_content = file_content.replace('"""', r"\"\"\"")
|
||||
result.append(f'{indent}with open({filename!r}, "w") as _f:')
|
||||
result.append(f'{indent} _f.write("""{file_content}""")')
|
||||
continue
|
||||
|
||||
# Handle ! shell commands
|
||||
if stripped.startswith("!"):
|
||||
cmd_lines = [stripped[1:]]
|
||||
while cmd_lines[-1].rstrip().endswith("\\") and i + 1 < len(lines):
|
||||
i += 1
|
||||
cmd_lines.append(lines[i].strip())
|
||||
full_cmd = "\n".join(cmd_lines)
|
||||
|
||||
f_prefix = "f" if needs_fstring(full_cmd) else ""
|
||||
if "\n" in full_cmd:
|
||||
escaped_cmd = full_cmd.replace('"""', r"\"\"\"")
|
||||
if escaped_cmd.rstrip().endswith('"'):
|
||||
escaped_cmd = escaped_cmd.rstrip() + " "
|
||||
result.append(
|
||||
f'{indent}subprocess.run({f_prefix}"""{escaped_cmd}""", shell=True)'
|
||||
)
|
||||
else:
|
||||
result.append(
|
||||
f"{indent}subprocess.run({f_prefix}{full_cmd!r}, shell=True)"
|
||||
)
|
||||
|
||||
# %cd path -> os.chdir(path)
|
||||
elif stripped.startswith("%cd "):
|
||||
path = stripped[4:].strip()
|
||||
result.append(f"{indent}os.chdir({path!r})")
|
||||
|
||||
# %env VAR=value
|
||||
elif stripped.startswith("%env ") and "=" in stripped:
|
||||
match = re.match(r"%env\s+(\w+)=(.+)", stripped)
|
||||
if match:
|
||||
var, val = match.groups()
|
||||
result.append(f"{indent}os.environ[{var!r}] = {val!r}")
|
||||
|
||||
# %env VAR
|
||||
elif stripped.startswith("%env "):
|
||||
var = stripped[5:].strip()
|
||||
result.append(f"{indent}os.environ.get({var!r})")
|
||||
|
||||
# %pwd
|
||||
elif stripped == "%pwd":
|
||||
result.append(f"{indent}os.getcwd()")
|
||||
|
||||
else:
|
||||
result.append(line)
|
||||
|
||||
i += 1
|
||||
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
def convert_notebook(notebook_content: str, source_name: str = "notebook") -> str:
|
||||
"""Convert notebook JSON content to Python script."""
|
||||
# Parse notebook
|
||||
if isinstance(notebook_content, str):
|
||||
notebook = nbformat.reads(notebook_content, as_version = 4)
|
||||
else:
|
||||
notebook = notebook_content
|
||||
|
||||
lines = [
|
||||
"#!/usr/bin/env python",
|
||||
"# coding: utf-8",
|
||||
f"# Converted from: {source_name}",
|
||||
"",
|
||||
"import subprocess",
|
||||
"import os",
|
||||
"import sys",
|
||||
"import re",
|
||||
"",
|
||||
"# Capture original packages before any installs",
|
||||
"_original_packages = subprocess.run(",
|
||||
" [sys.executable, '-m', 'pip', 'freeze'],",
|
||||
" capture_output=True, text=True",
|
||||
").stdout",
|
||||
"",
|
||||
"# Working directory (replaces Colab's /content/)",
|
||||
"_WORKING_DIR = os.getcwd()",
|
||||
"",
|
||||
]
|
||||
|
||||
for cell in notebook.cells:
|
||||
source = cell.source.strip()
|
||||
if not source:
|
||||
continue
|
||||
|
||||
if cell.cell_type == "code":
|
||||
converted = convert_cell_to_python(source)
|
||||
converted = replace_colab_paths(converted)
|
||||
lines.append(converted)
|
||||
lines.append("")
|
||||
|
||||
elif cell.cell_type == "markdown":
|
||||
for line in source.split("\n"):
|
||||
lines.append(f"# {line}")
|
||||
lines.append("")
|
||||
|
||||
# Add package restoration at the end
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"# Restore original packages (install one by one, skip failures)",
|
||||
"for _pkg in _original_packages.strip().split('\\n'):",
|
||||
" if _pkg:",
|
||||
" subprocess.run([sys.executable, '-m', 'pip', 'install', _pkg, '-q'],",
|
||||
" stderr=subprocess.DEVNULL)",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def convert_notebook_to_script(source: str, output_dir: str | None = None):
|
||||
"""
|
||||
Convert a notebook to Python script.
|
||||
|
||||
Args:
|
||||
source: Local file path or URL to notebook
|
||||
output_dir: Output directory (optional, defaults to current directory)
|
||||
"""
|
||||
if is_url(source):
|
||||
content, filename = download_notebook(source)
|
||||
source_name = source
|
||||
else:
|
||||
filename = os.path.basename(source)
|
||||
with open(source, "r", encoding = "utf-8") as f:
|
||||
content = f.read()
|
||||
source_name = source
|
||||
|
||||
# Generate output filename
|
||||
output_filename = filename.replace(".ipynb", ".py")
|
||||
# Clean up filename
|
||||
output_filename = (
|
||||
output_filename.replace("(", "").replace(")", "").replace("-", "_")
|
||||
)
|
||||
|
||||
# Add output directory if specified
|
||||
if output_dir:
|
||||
output_path = os.path.join(output_dir, output_filename)
|
||||
else:
|
||||
output_path = output_filename
|
||||
|
||||
# Convert
|
||||
script = convert_notebook(content, source_name)
|
||||
|
||||
# Write output
|
||||
with open(output_path, "w", encoding = "utf-8") as f:
|
||||
f.write(script)
|
||||
|
||||
print(f"Converted {source} -> {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
class Formatter(
|
||||
argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter
|
||||
):
|
||||
pass
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description = __doc__,
|
||||
formatter_class = Formatter,
|
||||
epilog = """
|
||||
Examples:
|
||||
python notebook_to_python.py notebook.ipynb
|
||||
python notebook_to_python.py -o scripts/ notebook1.ipynb notebook2.ipynb
|
||||
python notebook_to_python.py --output ./converted https://github.com/user/repo/blob/main/notebook.ipynb
|
||||
python notebook_to_python.py https://github.com/unslothai/notebooks/blob/main/nb/Oute_TTS_(1B).ipynb
|
||||
""",
|
||||
)
|
||||
parser.add_argument(
|
||||
"notebooks", nargs = "+", help = "Notebook files or URLs to convert."
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--output", dest = "output_dir", default = ".", help = "Output directory."
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Create output directory if needed
|
||||
os.makedirs(args.output_dir, exist_ok = True)
|
||||
|
||||
for source in args.notebooks:
|
||||
try:
|
||||
convert_notebook_to_script(
|
||||
source, output_dir = args.output_dir if args.output_dir != "." else None
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"ERROR converting {source}: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1298
scripts/notebook_validator.py
Normal file
1881
scripts/scan_packages.py
Normal file
|
|
@ -437,6 +437,7 @@ class LlamaCppBackend:
|
|||
self._effective_context_length: Optional[int] = None
|
||||
self._max_context_length: Optional[int] = None
|
||||
self._chat_template: Optional[str] = None
|
||||
self._chat_template_override: Optional[str] = None
|
||||
self._supports_reasoning: bool = False
|
||||
self._reasoning_always_on: bool = False
|
||||
self._reasoning_style: str = "enable_thinking"
|
||||
|
|
@ -621,6 +622,10 @@ class LlamaCppBackend:
|
|||
def chat_template(self) -> Optional[str]:
|
||||
return self._chat_template
|
||||
|
||||
@property
|
||||
def chat_template_override(self) -> Optional[str]:
|
||||
return self._chat_template_override
|
||||
|
||||
@property
|
||||
def supports_reasoning(self) -> bool:
|
||||
return self._supports_reasoning
|
||||
|
|
@ -2221,12 +2226,12 @@ class LlamaCppBackend:
|
|||
self._speculative_type = None
|
||||
|
||||
# Apply custom chat template override if provided
|
||||
self._chat_template_override = chat_template_override
|
||||
if chat_template_override:
|
||||
import tempfile
|
||||
|
||||
self._chat_template = chat_template_override
|
||||
flags = detect_reasoning_flags(
|
||||
self._chat_template,
|
||||
chat_template_override,
|
||||
self._model_identifier,
|
||||
log_source = "GGUF chat template override",
|
||||
)
|
||||
|
|
@ -2525,6 +2530,7 @@ class LlamaCppBackend:
|
|||
self._effective_context_length = None
|
||||
self._max_context_length = None
|
||||
self._chat_template = None
|
||||
self._chat_template_override = None
|
||||
self._supports_reasoning = False
|
||||
self._reasoning_always_on = False
|
||||
self._reasoning_style = "enable_thinking"
|
||||
|
|
@ -4211,6 +4217,8 @@ class LlamaCppBackend:
|
|||
return "csm"
|
||||
if len(_tok("<|startoftranscript|>")) == 1:
|
||||
return "whisper"
|
||||
if len(_tok("<audio_soft_token>")) == 1:
|
||||
return "audio_vlm"
|
||||
if (
|
||||
len(_tok("<|bicodec_semantic_0|>")) == 1
|
||||
and len(_tok("<|bicodec_global_0|>")) == 1
|
||||
|
|
|
|||
|
|
@ -78,6 +78,28 @@ class MLXInferenceBackend:
|
|||
model_name = config.identifier if hasattr(config, "identifier") else str(config)
|
||||
is_vision = getattr(config, "is_vision", False)
|
||||
|
||||
# GGUF guard. GGUF models are served via llama-server in the
|
||||
# parent process, NOT via mlx-lm in this MLX subprocess. The
|
||||
# route at studio/backend/routes/inference.py:592 (`if config.
|
||||
# is_gguf:`) is responsible for sending GGUF traffic to the
|
||||
# llama-server backend before reaching the MLX orchestrator.
|
||||
# If we end up here with is_gguf=True, the route's
|
||||
# `detect_gguf_model_remote` returned None on its first call
|
||||
# (transient HF Hub flake) but the subprocess re-detection
|
||||
# succeeded. The subprocess cannot reach into the parent's
|
||||
# llama-server, so all we can do is raise loudly so the caller
|
||||
# gets a clear error instead of a cryptic
|
||||
# "config.json does not exist" from mlx_lm.utils.load_model.
|
||||
if getattr(config, "is_gguf", False):
|
||||
raise RuntimeError(
|
||||
f"MLXInferenceBackend cannot load GGUF model '{model_name}': "
|
||||
f"GGUF models must be served by llama-server in the parent "
|
||||
f"process. The /api/inference/load route should have "
|
||||
f"detected this repo as GGUF before dispatching to the MLX "
|
||||
f"orchestrator -- this fallback indicates a transient HF "
|
||||
f"Hub failure during initial detection. Retry the request."
|
||||
)
|
||||
|
||||
if hf_token:
|
||||
import os
|
||||
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|||
|
||||
|
||||
def filter_sensitive_data(logger, method_name, event_dict):
|
||||
"""Structlog processor to filter out base64 data from logs."""
|
||||
"""Structlog processor to redact native path leases from logs."""
|
||||
|
||||
def filter_value(value):
|
||||
if isinstance(value, str):
|
||||
|
|
@ -87,13 +87,7 @@ def filter_sensitive_data(logger, method_name, event_dict):
|
|||
except Exception:
|
||||
pass
|
||||
value = _NATIVE_PATH_LEASE_RE.sub(r"\1<redacted native path lease>", value)
|
||||
if (
|
||||
isinstance(value, str)
|
||||
and len(value) > 100
|
||||
and ("," in value or "/" in value)
|
||||
):
|
||||
# Likely base64 data, truncate it
|
||||
return value[:20] + "..."
|
||||
return value
|
||||
elif isinstance(value, dict):
|
||||
return {
|
||||
k: "<redacted native path lease>"
|
||||
|
|
|
|||
|
|
@ -337,8 +337,17 @@ async def shutdown_server(
|
|||
|
||||
|
||||
@app.get("/api/system")
|
||||
async def get_system_info():
|
||||
"""Get system information"""
|
||||
async def get_system_info(
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""Get system information.
|
||||
|
||||
Gated behind auth: the response includes platform, Python version,
|
||||
GPU name, memory total, and ML package set -- enough to fingerprint
|
||||
a host. Studio's chat-only-mode design assumes only the local user
|
||||
reaches /api/system; in -H 0.0.0.0 / Colab / Tauri-relayed setups
|
||||
that assumption breaks unless we require a bearer.
|
||||
"""
|
||||
import platform
|
||||
import psutil
|
||||
from utils.hardware import get_device
|
||||
|
|
@ -378,8 +387,14 @@ async def get_gpu_visibility(
|
|||
|
||||
|
||||
@app.get("/api/system/hardware")
|
||||
async def get_hardware_info():
|
||||
"""Return GPU name, total VRAM, and key ML package versions."""
|
||||
async def get_hardware_info(
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""Return GPU name, total VRAM, and key ML package versions.
|
||||
|
||||
Gated behind auth alongside /api/system -- same fingerprinting
|
||||
concern. /api/system/gpu-visibility is also auth-gated already.
|
||||
"""
|
||||
from utils.hardware import get_gpu_summary, get_package_versions
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from .training import (
|
|||
TrainingRunMetrics,
|
||||
TrainingRunDetailResponse,
|
||||
TrainingRunDeleteResponse,
|
||||
TrainingRunUpdateRequest,
|
||||
)
|
||||
from .models import (
|
||||
CheckpointInfo,
|
||||
|
|
@ -81,6 +82,7 @@ __all__ = [
|
|||
"TrainingRunMetrics",
|
||||
"TrainingRunDetailResponse",
|
||||
"TrainingRunDeleteResponse",
|
||||
"TrainingRunUpdateRequest",
|
||||
# Model management schemas
|
||||
"ModelDetails",
|
||||
"LocalModelInfo",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,14 @@ import time
|
|||
import uuid
|
||||
from typing import Annotated, Any, Dict, Literal, Optional, List, Union
|
||||
|
||||
from pydantic import BaseModel, Discriminator, Field, Tag, model_validator
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Discriminator,
|
||||
Field,
|
||||
Tag,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
|
||||
class LoadRequest(BaseModel):
|
||||
|
|
@ -43,6 +50,16 @@ class LoadRequest(BaseModel):
|
|||
None,
|
||||
description = "Custom Jinja2 chat template to use instead of the model's default",
|
||||
)
|
||||
|
||||
@field_validator("chat_template_override")
|
||||
@classmethod
|
||||
def normalize_blank_chat_template_override(
|
||||
cls, value: Optional[str]
|
||||
) -> Optional[str]:
|
||||
if value is not None and value.strip() == "":
|
||||
return None
|
||||
return value
|
||||
|
||||
cache_type_kv: Optional[str] = Field(
|
||||
None,
|
||||
description = "KV cache data type for both K and V (e.g. 'f16', 'bf16', 'q8_0', 'q4_1', 'q5_1')",
|
||||
|
|
@ -299,10 +316,6 @@ class InferenceStatusResponse(BaseModel):
|
|||
supports_tools: bool = Field(
|
||||
False, description = "Whether the active model supports tool calling"
|
||||
)
|
||||
chat_template: Optional[str] = Field(
|
||||
None,
|
||||
description = "Jinja2 chat template string for the active model",
|
||||
)
|
||||
context_length: Optional[int] = Field(
|
||||
None, description = "Context length of the active model"
|
||||
)
|
||||
|
|
@ -314,6 +327,17 @@ class InferenceStatusResponse(BaseModel):
|
|||
None,
|
||||
description = "Model's native context length from GGUF metadata (not capped by VRAM)",
|
||||
)
|
||||
cache_type_kv: Optional[str] = Field(
|
||||
None,
|
||||
description = "KV cache quantization dtype (e.g. 'q8_0'), or None for default",
|
||||
)
|
||||
chat_template: Optional[str] = Field(
|
||||
None, description = "Model's default chat template (Jinja2 source), if any"
|
||||
)
|
||||
chat_template_override: Optional[str] = Field(
|
||||
None,
|
||||
description = "Active chat template override applied at load time, or None if model is using its default",
|
||||
)
|
||||
speculative_type: Optional[str] = Field(
|
||||
None,
|
||||
description = "Active speculative decoding mode (e.g. 'ngram-simple', 'ngram-mod'), or None if disabled",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
Pydantic schemas for Training API
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from typing import Any, Optional, List, Dict, Literal
|
||||
|
||||
|
||||
|
|
@ -224,6 +224,7 @@ class TrainingRunSummary(BaseModel):
|
|||
status: Literal["running", "completed", "stopped", "error"]
|
||||
model_name: str
|
||||
dataset_name: str
|
||||
display_name: Optional[str] = None
|
||||
started_at: str
|
||||
ended_at: Optional[str] = None
|
||||
total_steps: Optional[int] = None
|
||||
|
|
@ -237,6 +238,14 @@ class TrainingRunSummary(BaseModel):
|
|||
resumed_later: bool = False
|
||||
|
||||
|
||||
class TrainingRunUpdateRequest(BaseModel):
|
||||
"""Mutable fields on a training run."""
|
||||
|
||||
model_config = ConfigDict(extra = "forbid")
|
||||
|
||||
display_name: Optional[str] = Field(None, max_length = 120)
|
||||
|
||||
|
||||
class TrainingRunListResponse(BaseModel):
|
||||
"""Response for listing training runs."""
|
||||
|
||||
|
|
|
|||
|
|
@ -48,13 +48,31 @@ class ScrapeConfig:
|
|||
max_comments_per_item: int
|
||||
|
||||
|
||||
def _resolve_token(token: str) -> str:
|
||||
tok = token or os.environ.get("GH_TOKEN", "") or os.environ.get("GITHUB_TOKEN", "")
|
||||
if not tok:
|
||||
raise ValueError(
|
||||
"GitHub token is required. Set it in the recipe config or the GH_TOKEN / GITHUB_TOKEN env var."
|
||||
@dataclass(frozen = True)
|
||||
class ResolvedToken:
|
||||
value: str
|
||||
source: str
|
||||
|
||||
|
||||
def _resolve_token(token: str) -> ResolvedToken:
|
||||
if token:
|
||||
return ResolvedToken(
|
||||
value = token,
|
||||
source = "explicit token argument (recipe-level field)",
|
||||
)
|
||||
return tok
|
||||
if os.environ.get("GH_TOKEN"):
|
||||
return ResolvedToken(
|
||||
value = os.environ["GH_TOKEN"],
|
||||
source = "GH_TOKEN environment variable",
|
||||
)
|
||||
if os.environ.get("GITHUB_TOKEN"):
|
||||
return ResolvedToken(
|
||||
value = os.environ["GITHUB_TOKEN"],
|
||||
source = "GITHUB_TOKEN environment variable",
|
||||
)
|
||||
raise ValueError(
|
||||
"GitHub token is required. Set it in the recipe config or the GH_TOKEN / GITHUB_TOKEN env var."
|
||||
)
|
||||
|
||||
|
||||
def _read_jsonl(path: Path, max_rows: int | None = None):
|
||||
|
|
@ -155,7 +173,7 @@ def _flatten_commit_row(r: dict, repo: str) -> dict:
|
|||
def scrape(cfg: ScrapeConfig, base_dir: Path):
|
||||
token = _resolve_token(cfg.token)
|
||||
GitHubClient, RepoScraper = _load_impl()
|
||||
client = GitHubClient(token = token)
|
||||
client = GitHubClient(token = token.value, token_source = token.source)
|
||||
base_dir.mkdir(parents = True, exist_ok = True)
|
||||
|
||||
# Per-resource trial limits. limit <= 0 means "all": use a very large cap.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import json
|
|||
import os
|
||||
import time
|
||||
import logging
|
||||
from datetime import timezone
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Any, Dict, Iterable, Iterator, List, Optional
|
||||
|
||||
import requests
|
||||
|
|
@ -29,16 +31,46 @@ class RateLimitError(Exception):
|
|||
pass
|
||||
|
||||
|
||||
class GitHubAuthError(RuntimeError):
|
||||
"""Raised when GitHub returns 401/403 due to invalid or insufficient credentials."""
|
||||
|
||||
|
||||
def _retry_after_seconds(value: str | None) -> int | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return max(0, int(value))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
retry_at = parsedate_to_datetime(value)
|
||||
except (TypeError, ValueError, IndexError, OverflowError):
|
||||
return None
|
||||
if retry_at.tzinfo is None:
|
||||
retry_at = retry_at.replace(tzinfo = timezone.utc)
|
||||
return max(0, int(retry_at.timestamp() - time.time()))
|
||||
|
||||
|
||||
class GitHubClient:
|
||||
def __init__(
|
||||
self,
|
||||
min_remaining_graphql: int = 100,
|
||||
min_remaining_rest: int = 100,
|
||||
token: str | None = None,
|
||||
token_source: str | None = None,
|
||||
):
|
||||
token = token or os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
||||
if not token:
|
||||
raise RuntimeError("GH_TOKEN not set in environment")
|
||||
if token:
|
||||
self._token_source = (
|
||||
token_source or "explicit token argument (recipe-level field)"
|
||||
)
|
||||
elif os.environ.get("GH_TOKEN"):
|
||||
self._token_source = "GH_TOKEN environment variable"
|
||||
token = os.environ["GH_TOKEN"]
|
||||
elif os.environ.get("GITHUB_TOKEN"):
|
||||
self._token_source = "GITHUB_TOKEN environment variable"
|
||||
token = os.environ["GITHUB_TOKEN"]
|
||||
else:
|
||||
raise RuntimeError("GH_TOKEN or GITHUB_TOKEN not set in environment")
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{**BASE_HEADERS, "Authorization": f"Bearer {token}"}
|
||||
|
|
@ -59,6 +91,49 @@ class GitHubClient:
|
|||
log.warning("Rate limit hit. Sleeping %ds until reset.", wait)
|
||||
time.sleep(wait)
|
||||
|
||||
def _is_rate_limit_response(self, r: "requests.Response") -> bool:
|
||||
if r.headers.get("Retry-After"):
|
||||
return True
|
||||
if r.headers.get("X-RateLimit-Remaining") == "0":
|
||||
return True
|
||||
body = (r.text or "").lower()
|
||||
return any(
|
||||
marker in body
|
||||
for marker in (
|
||||
"api rate limit exceeded",
|
||||
"rate limit exceeded",
|
||||
"secondary rate limit",
|
||||
"secondary limit",
|
||||
"abuse detection mechanism",
|
||||
"abuse detection",
|
||||
)
|
||||
)
|
||||
|
||||
def _is_auth_failure(self, r: "requests.Response") -> bool:
|
||||
"""Distinguish auth failures from rate limiting on 401/403 responses.
|
||||
|
||||
- 401: always an auth failure (invalid / expired / wrong-scope token).
|
||||
- 403: an auth failure UNLESS the response carries a clear rate-limit signal
|
||||
(Retry-After header, X-RateLimit-Remaining: 0, or GitHub's secondary /
|
||||
abuse rate-limit response text).
|
||||
"""
|
||||
if r.status_code == 401:
|
||||
return True
|
||||
if r.status_code == 403:
|
||||
return not self._is_rate_limit_response(r)
|
||||
return False
|
||||
|
||||
def _raise_auth_error(self, r: "requests.Response", endpoint: str) -> None:
|
||||
snippet = (r.text or "").strip()[:200]
|
||||
request_id = r.headers.get("X-GitHub-Request-Id")
|
||||
request_id_message = f" Request ID: {request_id}." if request_id else ""
|
||||
raise GitHubAuthError(
|
||||
f"GitHub {endpoint} returned {r.status_code} {r.reason}. "
|
||||
f"Token source: {self._token_source}. "
|
||||
f"The token is invalid, expired, or missing required scopes — "
|
||||
f"retrying will not recover.{request_id_message} Response: {snippet}"
|
||||
)
|
||||
|
||||
def _check_rate_and_wait(self, kind: str) -> None:
|
||||
if kind == "graphql":
|
||||
remaining = self.graphql_remaining
|
||||
|
|
@ -112,13 +187,14 @@ class GitHubClient:
|
|||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60)
|
||||
continue
|
||||
if self._is_auth_failure(r):
|
||||
self._raise_auth_error(r, "GraphQL")
|
||||
if r.status_code == 403 or r.status_code == 429:
|
||||
# Check for secondary/abuse
|
||||
retry_after = r.headers.get("Retry-After")
|
||||
if retry_after:
|
||||
t = int(retry_after)
|
||||
log.warning("Secondary rate limit. Sleep %ds.", t)
|
||||
time.sleep(t + 2)
|
||||
retry_after = _retry_after_seconds(r.headers.get("Retry-After"))
|
||||
if retry_after is not None:
|
||||
log.warning("Secondary rate limit. Sleep %ds.", retry_after)
|
||||
time.sleep(retry_after + 2)
|
||||
continue
|
||||
if self.graphql_reset:
|
||||
self._sleep_until(self.graphql_reset)
|
||||
|
|
@ -188,12 +264,15 @@ class GitHubClient:
|
|||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60)
|
||||
continue
|
||||
if self._is_auth_failure(r):
|
||||
self._raise_auth_error(r, "REST")
|
||||
if r.status_code in (403, 429):
|
||||
retry_after = r.headers.get("Retry-After")
|
||||
if retry_after:
|
||||
t = int(retry_after)
|
||||
log.warning("Secondary rate limit on REST. Sleep %ds.", t)
|
||||
time.sleep(t + 2)
|
||||
retry_after = _retry_after_seconds(r.headers.get("Retry-After"))
|
||||
if retry_after is not None:
|
||||
log.warning(
|
||||
"Secondary rate limit on REST. Sleep %ds.", retry_after
|
||||
)
|
||||
time.sleep(retry_after + 2)
|
||||
continue
|
||||
# Check if primary rate
|
||||
if self.rest_remaining == 0 and self.rest_reset:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,28 @@
|
|||
|
||||
# unsloth direct deps (from pyproject.toml [project].dependencies)
|
||||
typer
|
||||
# typer's full runtime dep tree. Required explicitly because this
|
||||
# file is installed with --no-deps. On Linux/Mac CI runners these
|
||||
# are often cached transitively; on a fresh windows-latest venv they
|
||||
# are not, and `unsloth studio setup` crashes with
|
||||
# `ModuleNotFoundError: No module named 'click'`, then 'annotated_doc',
|
||||
# then 'rich', etc. as each is hit. Pin the full chain so the
|
||||
# no-torch path works cleanly on every fresh venv.
|
||||
click>=8.0
|
||||
shellingham>=1.5
|
||||
annotated-doc>=0.0.3
|
||||
rich>=13.0
|
||||
markdown-it-py>=3.0
|
||||
mdurl>=0.1
|
||||
pygments>=2.0
|
||||
pydantic
|
||||
# pydantic 2.x deps. With --no-deps, `import pydantic` blows up
|
||||
# with `ModuleNotFoundError: 'pydantic_core'` (compiled Rust core,
|
||||
# separate wheel), then `'annotated_types'`, then
|
||||
# `'typing_inspection'` (used by pydantic 2.10+ for fields).
|
||||
pydantic-core
|
||||
annotated-types>=0.6
|
||||
typing-inspection>=0.4
|
||||
pyyaml
|
||||
nest-asyncio
|
||||
|
||||
|
|
@ -42,7 +63,9 @@ anyio
|
|||
sniffio
|
||||
h11
|
||||
|
||||
tokenizers
|
||||
# Unpinned resolves to 0.23.1+ which breaks `from transformers import
|
||||
# AutoConfig`; transformers 4.56..5.3 declares tokenizers<=0.23.0.
|
||||
tokenizers<=0.23.0
|
||||
transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.3.0
|
||||
trl>=0.18.2,!=0.19.0,<=0.24.0
|
||||
sentence-transformers
|
||||
|
|
|
|||
|
|
@ -474,7 +474,6 @@ async def load_model(
|
|||
f"Model already loaded (GGUF): {model_log_label} variant={request.gguf_variant}, skipping reload"
|
||||
)
|
||||
inference_config = load_inference_config(llama_backend.model_identifier)
|
||||
from utils.models import is_audio_input_type
|
||||
|
||||
_gguf_audio = (
|
||||
llama_backend._audio_type
|
||||
|
|
@ -495,9 +494,7 @@ async def load_model(
|
|||
is_gguf = True,
|
||||
is_audio = _gguf_is_audio,
|
||||
audio_type = _gguf_audio,
|
||||
has_audio_input = is_audio_input_type(_gguf_audio)
|
||||
if _gguf_audio
|
||||
else False,
|
||||
has_audio_input = False,
|
||||
inference = inference_config,
|
||||
requires_trust_remote_code = bool(
|
||||
inference_config.get("trust_remote_code", False)
|
||||
|
|
@ -658,9 +655,10 @@ async def load_model(
|
|||
f"Loaded GGUF model via llama-server: {model_log_label if native_grant_backed else config.identifier}"
|
||||
)
|
||||
|
||||
# Detect TTS audio by probing the loaded model's vocabulary
|
||||
from utils.models import is_audio_input_type
|
||||
|
||||
# Detect TTS/audio marker tokens by probing the loaded model's vocabulary.
|
||||
# GGUF audio input is not wired through the chat path yet, so do not
|
||||
# advertise has_audio_input for GGUF models until uploaded audio is
|
||||
# actually forwarded to llama-server.
|
||||
_gguf_audio = llama_backend.detect_audio_type()
|
||||
_gguf_is_audio = _gguf_audio in ("snac", "bicodec", "dac")
|
||||
llama_backend._is_audio = _gguf_is_audio
|
||||
|
|
@ -686,7 +684,7 @@ async def load_model(
|
|||
is_gguf = True,
|
||||
is_audio = _gguf_is_audio,
|
||||
audio_type = _gguf_audio,
|
||||
has_audio_input = is_audio_input_type(_gguf_audio),
|
||||
has_audio_input = False,
|
||||
inference = inference_config,
|
||||
requires_trust_remote_code = bool(
|
||||
inference_config.get("trust_remote_code", False)
|
||||
|
|
@ -1156,13 +1154,15 @@ async def get_status(
|
|||
):
|
||||
_display_model_id = os.path.basename(_model_id)
|
||||
_inference_cfg = load_inference_config(_model_id) if _model_id else None
|
||||
_audio_type = getattr(llama_backend, "_audio_type", None)
|
||||
return InferenceStatusResponse(
|
||||
active_model = _display_model_id,
|
||||
is_vision = llama_backend.is_vision,
|
||||
is_gguf = True,
|
||||
gguf_variant = llama_backend.hf_variant,
|
||||
is_audio = getattr(llama_backend, "_is_audio", False),
|
||||
audio_type = getattr(llama_backend, "_audio_type", None),
|
||||
audio_type = _audio_type,
|
||||
has_audio_input = False,
|
||||
loading = [],
|
||||
loaded = [_display_model_id] if _display_model_id else [],
|
||||
inference = _inference_cfg,
|
||||
|
|
@ -1178,6 +1178,8 @@ async def get_status(
|
|||
context_length = llama_backend.context_length,
|
||||
max_context_length = llama_backend.max_context_length,
|
||||
native_context_length = llama_backend.native_context_length,
|
||||
cache_type_kv = llama_backend.cache_type_kv,
|
||||
chat_template_override = llama_backend.chat_template_override,
|
||||
speculative_type = llama_backend.speculative_type,
|
||||
)
|
||||
|
||||
|
|
@ -1669,6 +1671,12 @@ async def openai_chat_completions(
|
|||
and not _effective_enable_tools(payload)
|
||||
and (_tools_passthrough or _has_response_format)
|
||||
):
|
||||
if payload.audio_base64:
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "Audio input is not supported for GGUF chat models yet.",
|
||||
)
|
||||
|
||||
# Preserve the vision guard that would otherwise run in the
|
||||
# non-passthrough path below: text-only tool-capable GGUFs
|
||||
# should return a clear 400 here rather than forwarding the
|
||||
|
|
@ -1716,6 +1724,12 @@ async def openai_chat_completions(
|
|||
|
||||
# ── GGUF path: proxy to llama-server /v1/chat/completions ──
|
||||
if using_gguf:
|
||||
if payload.audio_base64:
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "Audio input is not supported for GGUF chat models yet.",
|
||||
)
|
||||
|
||||
# Reject images if this GGUF model doesn't support vision
|
||||
image_b64 = extracted_image_b64 or payload.image_base64
|
||||
if image_b64 and not llama_backend.is_vision:
|
||||
|
|
|
|||
|
|
@ -18,8 +18,15 @@ from models import (
|
|||
TrainingRunListResponse,
|
||||
TrainingRunMetrics,
|
||||
TrainingRunSummary,
|
||||
TrainingRunUpdateRequest,
|
||||
)
|
||||
from storage.studio_db import (
|
||||
delete_run,
|
||||
get_run,
|
||||
get_run_metrics,
|
||||
list_runs,
|
||||
update_run_display_name,
|
||||
)
|
||||
from storage.studio_db import delete_run, get_run, get_run_metrics, list_runs
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
|
@ -73,6 +80,34 @@ async def get_training_run_detail(
|
|||
)
|
||||
|
||||
|
||||
@router.patch("/runs/{run_id}", response_model = TrainingRunSummary)
|
||||
async def update_training_run(
|
||||
run_id: str,
|
||||
payload: TrainingRunUpdateRequest,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""Update mutable fields on a training run (currently only display_name)."""
|
||||
run = get_run(run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code = 404, detail = f"Run {run_id} not found")
|
||||
|
||||
if "display_name" in payload.model_fields_set:
|
||||
next_display = payload.display_name
|
||||
if next_display is not None:
|
||||
next_display = next_display.strip() or None
|
||||
update_run_display_name(run_id, next_display)
|
||||
|
||||
refreshed = get_run(run_id)
|
||||
if refreshed is None:
|
||||
raise HTTPException(status_code = 404, detail = f"Run {run_id} not found")
|
||||
return TrainingRunSummary(
|
||||
**{
|
||||
**{k: v for k, v in refreshed.items() if k != "config_json"},
|
||||
"can_resume": can_resume_run(refreshed),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/runs/{run_id}", response_model = TrainingRunDeleteResponse)
|
||||
async def delete_training_run(
|
||||
run_id: str,
|
||||
|
|
|
|||
|
|
@ -75,10 +75,16 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|||
output_dir TEXT,
|
||||
error_message TEXT,
|
||||
duration_seconds REAL,
|
||||
loss_sparkline TEXT
|
||||
loss_sparkline TEXT,
|
||||
display_name TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
existing_cols = {
|
||||
row[1] for row in conn.execute("PRAGMA table_info(training_runs)").fetchall()
|
||||
}
|
||||
if "display_name" not in existing_cols:
|
||||
conn.execute("ALTER TABLE training_runs ADD COLUMN display_name TEXT")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS training_metrics (
|
||||
|
|
@ -261,6 +267,18 @@ def insert_metrics_batch(run_id: str, metrics: list[dict]) -> None:
|
|||
conn.close()
|
||||
|
||||
|
||||
def update_run_display_name(id: str, display_name: Optional[str]) -> None:
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE training_runs SET display_name = ? WHERE id = ?",
|
||||
(display_name, id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_runs(limit: int = 50, offset: int = 0) -> dict:
|
||||
conn = get_connection()
|
||||
try:
|
||||
|
|
@ -270,7 +288,7 @@ def list_runs(limit: int = 50, offset: int = 0) -> dict:
|
|||
SELECT r.id, r.status, r.model_name, r.dataset_name, r.started_at,
|
||||
r.ended_at, r.total_steps, r.final_step, r.final_loss,
|
||||
r.output_dir, r.duration_seconds, r.error_message,
|
||||
r.loss_sparkline,
|
||||
r.loss_sparkline, r.display_name,
|
||||
CASE
|
||||
WHEN r.status = 'stopped'
|
||||
AND r.output_dir IS NOT NULL
|
||||
|
|
|
|||
36
studio/backend/tests/test_inference_model_validation.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
_backend = os.path.join(os.path.dirname(__file__), "..")
|
||||
sys.path.insert(0, _backend)
|
||||
|
||||
from models.inference import LoadRequest
|
||||
|
||||
|
||||
def _base_load_request(**overrides):
|
||||
data = {
|
||||
"model_path": "unsloth/test-model-GGUF",
|
||||
"hf_token": None,
|
||||
"max_seq_length": 4096,
|
||||
"load_in_4bit": True,
|
||||
"is_lora": False,
|
||||
"gguf_variant": "Q4_K_M",
|
||||
}
|
||||
data.update(overrides)
|
||||
return LoadRequest.model_validate(data)
|
||||
|
||||
|
||||
def test_blank_chat_template_override_normalizes_to_none():
|
||||
req = _base_load_request(chat_template_override = " \n\t")
|
||||
|
||||
assert req.chat_template_override is None
|
||||
|
||||
|
||||
def test_nonblank_chat_template_override_is_preserved_verbatim():
|
||||
template = " {{ messages }} "
|
||||
req = _base_load_request(chat_template_override = template)
|
||||
|
||||
assert req.chat_template_override == template
|
||||
108
studio/backend/tests/test_log_filter_no_truncation.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Regression tests for studio.backend.loggers.handlers.filter_sensitive_data.
|
||||
|
||||
Context: filter_sensitive_data was originally written with a base64-detection
|
||||
heuristic that truncated any string >100 chars containing ',' or '/' down to
|
||||
20 chars + '...'. The block was dormant until PR #5246 wired the processor
|
||||
into the structlog chain to redact native-path leases. Once active, the
|
||||
heuristic ate normal log lines emitted by llama_cpp_backend (GGUF size
|
||||
summary, mmproj selection, the full llama-server command line) and any
|
||||
exception traceback that happened to contain a file path.
|
||||
|
||||
These tests pin two properties:
|
||||
|
||||
1. Long, comma- or slash-bearing log messages flow through filter_sensitive_data
|
||||
unchanged. The exact strings exercised match the call sites at
|
||||
studio/backend/core/inference/llama_cpp.py:2117, :2283, and :2312 that
|
||||
were truncated in the original bug report.
|
||||
|
||||
2. PR #5246's native-path lease redaction still fires for both the inline
|
||||
``native_path_lease=...`` regex form and the ``nativePathLease`` dict-key
|
||||
form. This guards against future regressions that strip redaction along
|
||||
with the truncation block.
|
||||
"""
|
||||
|
||||
from loggers.handlers import filter_sensitive_data
|
||||
|
||||
|
||||
def _run(event_dict):
|
||||
return filter_sensitive_data(logger = None, method_name = "info", event_dict = event_dict)
|
||||
|
||||
|
||||
class TestNoTruncation:
|
||||
def test_gguf_size_summary_survives(self):
|
||||
# Mirrors the f-string at studio/backend/core/inference/llama_cpp.py:2117
|
||||
event = (
|
||||
"GGUF size: 232.9 GB, est. KV cache: 87.0 GB, context: 259072, "
|
||||
"GPUs free: [(0, 80000), (1, 80000)], selected: [0, 1], fit: False"
|
||||
)
|
||||
out = _run({"event": event})
|
||||
assert out["event"] == event
|
||||
assert "..." not in out["event"]
|
||||
|
||||
def test_mmproj_path_survives(self):
|
||||
# Mirrors logger.info at studio/backend/core/inference/llama_cpp.py:2283
|
||||
event = (
|
||||
"Using mmproj for vision: "
|
||||
"/home/user/.cache/unsloth/models/some-vision-model-uncensored-r1-distill/mmproj-F16.gguf"
|
||||
)
|
||||
out = _run({"event": event})
|
||||
assert out["event"] == event
|
||||
|
||||
def test_llama_server_command_survives(self):
|
||||
# Mirrors logger.info at studio/backend/core/inference/llama_cpp.py:2312
|
||||
event = (
|
||||
"Starting llama-server: /home/user/.unsloth/studio/llama.cpp/build/bin/llama-server "
|
||||
"-m /home/user/.cache/unsloth/models/foo.gguf --port 8090 -c 259072 --parallel 1 "
|
||||
"--flash-attn on --mmproj /home/user/.cache/unsloth/models/mmproj-F16.gguf"
|
||||
)
|
||||
out = _run({"event": event})
|
||||
assert out["event"] == event
|
||||
|
||||
def test_traceback_with_paths_survives(self):
|
||||
traceback_str = (
|
||||
"Traceback (most recent call last):\n"
|
||||
' File "/home/user/.unsloth/studio/unsloth_studio/lib/python3.11/site-packages/'
|
||||
'studio/backend/core/inference/llama_cpp.py", line 2312, in start\n'
|
||||
' raise RuntimeError("llama-server crashed: bad alloc, /dev/shm full")\n'
|
||||
"RuntimeError: llama-server crashed: bad alloc, /dev/shm full"
|
||||
)
|
||||
out = _run({"event": "llama-server crashed", "exception": traceback_str})
|
||||
assert out["exception"] == traceback_str
|
||||
assert "..." not in out["exception"]
|
||||
|
||||
def test_nested_long_string_in_dict_survives(self):
|
||||
long_value = (
|
||||
"/very/long/path/with,many,commas,and/slashes/that/used/to/get/"
|
||||
"chopped/to/twenty/chars/file.gguf"
|
||||
)
|
||||
out = _run({"event": "load", "details": {"path": long_value}})
|
||||
assert out["details"]["path"] == long_value
|
||||
|
||||
|
||||
class TestNativePathLeaseRedactionStillWorks:
|
||||
"""Guards PR #5246's redaction from being lost alongside the truncation block."""
|
||||
|
||||
def test_inline_native_path_lease_value_redacted(self):
|
||||
event = (
|
||||
"rejected request: native_path_lease=AAAAAA.BBBBBB extra context "
|
||||
"with /some/path,values"
|
||||
)
|
||||
out = _run({"event": event})
|
||||
assert "AAAAAA.BBBBBB" not in out["event"]
|
||||
assert "<redacted native path lease>" in out["event"]
|
||||
|
||||
def test_camelcase_native_path_lease_dict_key_redacted(self):
|
||||
out = _run({"event": "load", "nativePathLease": "AAAAAA.BBBBBB"})
|
||||
assert out["nativePathLease"] == "<redacted native path lease>"
|
||||
|
||||
def test_snakecase_native_path_lease_dict_key_redacted(self):
|
||||
out = _run({"event": "load", "native_path_lease": "AAAAAA.BBBBBB"})
|
||||
assert out["native_path_lease"] == "<redacted native path lease>"
|
||||
|
||||
def test_nested_native_path_lease_key_redacted(self):
|
||||
out = _run({"event": "load", "payload": {"nativePathLease": "AAAAAA.BBBBBB"}})
|
||||
assert out["payload"]["nativePathLease"] == "<redacted native path lease>"
|
||||
100
studio/backend/tests/test_training_history_update.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
_backend = os.path.join(os.path.dirname(__file__), "..")
|
||||
sys.path.insert(0, _backend)
|
||||
|
||||
from models.training import TrainingRunUpdateRequest
|
||||
from routes import training_history
|
||||
|
||||
|
||||
BASE_RUN = {
|
||||
"id": "run-1",
|
||||
"status": "stopped",
|
||||
"model_name": "unsloth/test-model",
|
||||
"dataset_name": "test-dataset",
|
||||
"display_name": "Existing name",
|
||||
"started_at": "2026-01-01T00:00:00Z",
|
||||
"ended_at": "2026-01-01T00:01:00Z",
|
||||
"total_steps": 10,
|
||||
"final_step": 5,
|
||||
"output_dir": "/tmp/run-1",
|
||||
"resumed_later": False,
|
||||
}
|
||||
|
||||
|
||||
def _patch_run(monkeypatch: pytest.MonkeyPatch, payload: TrainingRunUpdateRequest):
|
||||
stored = dict(BASE_RUN)
|
||||
calls: list[str | None] = []
|
||||
|
||||
def fake_get_run(run_id: str):
|
||||
assert run_id == "run-1"
|
||||
return dict(stored)
|
||||
|
||||
def fake_update_run_display_name(run_id: str, display_name: str | None):
|
||||
assert run_id == "run-1"
|
||||
calls.append(display_name)
|
||||
stored["display_name"] = display_name
|
||||
|
||||
monkeypatch.setattr(training_history, "get_run", fake_get_run)
|
||||
monkeypatch.setattr(
|
||||
training_history,
|
||||
"update_run_display_name",
|
||||
fake_update_run_display_name,
|
||||
)
|
||||
monkeypatch.setattr(training_history, "can_resume_run", lambda run: True)
|
||||
|
||||
result = asyncio.run(
|
||||
training_history.update_training_run(
|
||||
"run-1",
|
||||
payload,
|
||||
current_subject = "test-user",
|
||||
)
|
||||
)
|
||||
return result, calls
|
||||
|
||||
|
||||
def test_update_run_omitted_display_name_is_noop(monkeypatch: pytest.MonkeyPatch):
|
||||
result, calls = _patch_run(monkeypatch, TrainingRunUpdateRequest.model_validate({}))
|
||||
|
||||
assert calls == []
|
||||
assert result.display_name == "Existing name"
|
||||
assert result.can_resume is True
|
||||
|
||||
|
||||
def test_update_run_explicit_null_clears_display_name(monkeypatch: pytest.MonkeyPatch):
|
||||
result, calls = _patch_run(
|
||||
monkeypatch,
|
||||
TrainingRunUpdateRequest.model_validate({"display_name": None}),
|
||||
)
|
||||
|
||||
assert calls == [None]
|
||||
assert result.display_name is None
|
||||
assert result.can_resume is True
|
||||
|
||||
|
||||
def test_update_run_whitespace_clears_display_name(monkeypatch: pytest.MonkeyPatch):
|
||||
result, calls = _patch_run(
|
||||
monkeypatch,
|
||||
TrainingRunUpdateRequest.model_validate({"display_name": " "}),
|
||||
)
|
||||
|
||||
assert calls == [None]
|
||||
assert result.display_name is None
|
||||
|
||||
|
||||
def test_update_run_rejects_unknown_fields():
|
||||
with pytest.raises(ValidationError):
|
||||
TrainingRunUpdateRequest.model_validate({"unknown": "value"})
|
||||
|
||||
|
||||
def test_update_run_rejects_overlong_display_name():
|
||||
with pytest.raises(ValidationError):
|
||||
TrainingRunUpdateRequest.model_validate({"display_name": "x" * 121})
|
||||
|
|
@ -1327,16 +1327,42 @@ def detect_gguf_model_remote(
|
|||
Check if a HuggingFace repo contains GGUF files.
|
||||
|
||||
Returns the filename of the best GGUF file in the repo, or None.
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import model_info as hf_model_info
|
||||
|
||||
info = hf_model_info(repo_id, token = hf_token)
|
||||
repo_files = [s.rfilename for s in info.siblings]
|
||||
return _pick_best_gguf(repo_files)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not check GGUF files for '{repo_id}': {e}")
|
||||
return None
|
||||
Retries on transient HF Hub failures (network hiccups, 5xx, slow
|
||||
cold-start of the API). Without retry, a single transient failure
|
||||
here returns None silently and the caller treats the repo as
|
||||
non-GGUF -- which on Apple Silicon (Mac UI route) means falling
|
||||
through to the MLX backend, which then fails opening a non-existent
|
||||
config.json on the GGUF-only repo. Three attempts with 1s/2s/4s
|
||||
backoff covers the typical free-runner HF Hub flakiness.
|
||||
"""
|
||||
import time
|
||||
from huggingface_hub import model_info as hf_model_info
|
||||
|
||||
last_err: Optional[Exception] = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
info = hf_model_info(repo_id, token = hf_token)
|
||||
repo_files = [s.rfilename for s in info.siblings]
|
||||
return _pick_best_gguf(repo_files)
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
# 404 / RepoNotFound is permanent -- don't waste attempts.
|
||||
err_name = type(e).__name__
|
||||
if err_name in (
|
||||
"RepositoryNotFoundError",
|
||||
"GatedRepoError",
|
||||
"RevisionNotFoundError",
|
||||
"EntryNotFoundError",
|
||||
):
|
||||
logger.debug(f"Could not check GGUF files for '{repo_id}': {e}")
|
||||
return None
|
||||
if attempt < 2:
|
||||
time.sleep(2**attempt)
|
||||
logger.warning(
|
||||
f"Could not check GGUF files for '{repo_id}' after 3 attempts: " f"{last_err}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def download_gguf_file(
|
||||
|
|
|
|||
1
studio/frontend/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
|||
"@assistant-ui/react": "0.12.28",
|
||||
"@assistant-ui/react-markdown": "0.12.11",
|
||||
"@assistant-ui/react-streamdown": "0.1.11",
|
||||
"@assistant-ui/tap": "0.5.10",
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
"@dagrejs/graphlib": "^3.0.4",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"@assistant-ui/react": "0.12.28",
|
||||
"@assistant-ui/react-markdown": "0.12.11",
|
||||
"@assistant-ui/react-streamdown": "0.1.11",
|
||||
"@assistant-ui/tap": "0.5.10",
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
"@dagrejs/graphlib": "^3.0.4",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 9 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 159 KiB |
|
|
@ -28,6 +28,16 @@ import {
|
|||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
|
|
@ -35,8 +45,8 @@ import {
|
|||
ColumnInsertIcon,
|
||||
CursorInfo02Icon,
|
||||
Delete02Icon,
|
||||
Download03Icon,
|
||||
GemIcon,
|
||||
DownloadSquare01Icon,
|
||||
Edit03Icon,
|
||||
Globe02Icon,
|
||||
HelpCircleIcon,
|
||||
Search01Icon,
|
||||
|
|
@ -44,6 +54,7 @@ import {
|
|||
PencilEdit02Icon,
|
||||
LayoutAlignLeftIcon,
|
||||
Settings02Icon,
|
||||
TestTube01Icon,
|
||||
ZapIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
|
|
@ -52,25 +63,34 @@ import {
|
|||
} from "@/components/ui/tooltip";
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ChevronDown, ChevronsUpDown, Moon, Sun } from "lucide-react";
|
||||
import { ChevronDown, ChevronsUpDown, MoreHorizontalIcon, Moon, Sun } from "lucide-react";
|
||||
import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import { useTrainingRuntimeStore } from "@/features/training";
|
||||
import {
|
||||
ChatSearchDialog,
|
||||
deleteChatItem,
|
||||
renameChatItem,
|
||||
useChatRuntimeStore,
|
||||
useChatSearchStore,
|
||||
useChatSidebarItems,
|
||||
type SidebarItem,
|
||||
} from "@/features/chat";
|
||||
import { useSettingsDialogStore } from "@/features/settings";
|
||||
import { useEffectiveProfile, UserAvatar } from "@/features/profile";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { TOUR_OPEN_EVENT } from "@/features/tour";
|
||||
import {
|
||||
useChatSidebarItems,
|
||||
deleteChatItem,
|
||||
} from "@/features/chat/hooks/use-chat-sidebar-items";
|
||||
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
|
||||
import { useChatSearchStore } from "@/features/chat/stores/chat-search-store";
|
||||
import { ChatSearchDialog } from "@/features/chat/components/chat-search-dialog";
|
||||
import { useTrainingHistorySidebarItems, deleteTrainingRun } from "@/features/training";
|
||||
deleteTrainingRun,
|
||||
emitTrainingRunDeleted,
|
||||
emitTrainingRunUpdated,
|
||||
removeTrainingUnloadGuard,
|
||||
renameTrainingRun,
|
||||
useTrainingHistorySidebarItems,
|
||||
useTrainingRuntimeStore,
|
||||
} from "@/features/training";
|
||||
import type { TrainingRunSummary } from "@/features/training";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ShutdownDialog } from "@/components/shutdown-dialog";
|
||||
import { removeTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
|
||||
|
||||
function getTourId(pathname: string): string | null {
|
||||
if (pathname.startsWith("/studio")) return "studio";
|
||||
|
|
@ -79,6 +99,16 @@ function getTourId(pathname: string): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Hugeicons' TestTube01Icon ships with two interior bubbles (paths #4
|
||||
// and #5 of the 5-path definition). Slicing to the first three paths
|
||||
// keeps the test-tube outline + horizontal cap + liquid line, dropping
|
||||
// the bubbles. The original export stays untouched, and HugeiconsIcon
|
||||
// renders this trimmed array exactly the same way.
|
||||
const TestTubeOutlineIcon = TestTube01Icon.slice(
|
||||
0,
|
||||
3,
|
||||
) as typeof TestTube01Icon;
|
||||
|
||||
function runStatusDotClass(status: TrainingRunSummary["status"]): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
|
|
@ -141,10 +171,10 @@ function NavItem({
|
|||
onClick={onClick}
|
||||
isActive={active}
|
||||
data-tour={dataTour}
|
||||
className="h-[32px] rounded-[10px] gap-[8.5px] px-2.5 font-medium text-[#383835] dark:text-[#c7c7c4] hover:bg-[#f0f0f0]! dark:hover:bg-[#2a2c2f]! hover:text-black! dark:hover:text-white! data-active:bg-[#f0f0f0]! dark:data-active:bg-[#2a2c2f]! data-active:text-black! dark:data-active:text-white! group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-[11px] group-data-[collapsible=icon]:mx-auto"
|
||||
className="sidebar-nav-btn h-[35px] rounded-[10px] gap-[8.5px] px-2.5 font-medium group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-[10px] group-data-[collapsible=icon]:mx-auto"
|
||||
>
|
||||
<HugeiconsIcon icon={icon} strokeWidth={1.75} className="size-[18px]! shrink-0 group-hover/menu-button:animate-icon-pop" />
|
||||
<span className="text-[14px] leading-[18px] tracking-[0.01em]">{label}</span>
|
||||
<HugeiconsIcon icon={icon} strokeWidth={1.75} className="size-icon! shrink-0 group-hover/menu-button:animate-icon-pop" />
|
||||
<span className="text-[14.5px] leading-[19px] tracking-nav">{label}</span>
|
||||
</SidebarMenuButton>
|
||||
</div>
|
||||
{children}
|
||||
|
|
@ -181,6 +211,17 @@ export function AppSidebar() {
|
|||
useEffect(() => { if (isChatRoute) setChatOpen(true); }, [isChatRoute]);
|
||||
useEffect(() => { if (isStudioRoute) setRunsOpen(true); }, [isStudioRoute]);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const handler = () => setScrolled(el.scrollTop > 0);
|
||||
handler();
|
||||
el.addEventListener("scroll", handler, { passive: true });
|
||||
return () => el.removeEventListener("scroll", handler);
|
||||
}, []);
|
||||
|
||||
const isRecipesRoute = pathname.startsWith("/data-recipes");
|
||||
const { displayTitle, avatarDataUrl } = useEffectiveProfile();
|
||||
|
||||
|
|
@ -195,7 +236,7 @@ export function AppSidebar() {
|
|||
: undefined;
|
||||
|
||||
// Training runs
|
||||
const { items: runItems, refresh: refreshRuns } = useTrainingHistorySidebarItems(
|
||||
const { items: runItems } = useTrainingHistorySidebarItems(
|
||||
!chatOnly && isStudioRoute,
|
||||
);
|
||||
const activeJobId = useTrainingRuntimeStore((s) => s.jobId);
|
||||
|
|
@ -213,6 +254,93 @@ export function AppSidebar() {
|
|||
});
|
||||
}
|
||||
|
||||
type RenameTarget =
|
||||
| { kind: "chat"; item: SidebarItem; current: string }
|
||||
| { kind: "run"; run: TrainingRunSummary; current: string };
|
||||
const [renamingTarget, setRenamingTarget] = useState<RenameTarget | null>(
|
||||
null,
|
||||
);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
const renameTrimmed = renameDraft.trim();
|
||||
const nextRunDisplayName = renameTrimmed.length > 0 ? renameTrimmed : null;
|
||||
const renameDirty =
|
||||
renamingTarget !== null &&
|
||||
(renamingTarget.kind === "chat"
|
||||
? renameTrimmed.length > 0 && renameTrimmed !== renamingTarget.current
|
||||
: renameTrimmed.length > 0
|
||||
? renameTrimmed !== renamingTarget.current
|
||||
: renamingTarget.run.display_name != null);
|
||||
|
||||
function openRenameChat(item: SidebarItem) {
|
||||
setRenameDraft(item.title);
|
||||
setRenamingTarget({ kind: "chat", item, current: item.title });
|
||||
}
|
||||
function openRenameRun(run: TrainingRunSummary) {
|
||||
const current = run.display_name ?? run.model_name;
|
||||
setRenameDraft(current);
|
||||
setRenamingTarget({ kind: "run", run, current });
|
||||
}
|
||||
async function commitRename() {
|
||||
const target = renamingTarget;
|
||||
if (!target || !renameDirty) return;
|
||||
setRenamingTarget(null);
|
||||
if (target.kind === "chat") {
|
||||
try {
|
||||
await renameChatItem(target.item, renameTrimmed);
|
||||
} catch (err) {
|
||||
toast.error("Failed to rename chat", {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const updated = await renameTrainingRun(target.run.id, nextRunDisplayName);
|
||||
emitTrainingRunUpdated(updated);
|
||||
} catch (err) {
|
||||
toast.error("Failed to rename run", {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type DeleteTarget =
|
||||
| { kind: "chat"; item: SidebarItem }
|
||||
| { kind: "run"; run: TrainingRunSummary };
|
||||
const [confirmingDelete, setConfirmingDelete] =
|
||||
useState<DeleteTarget | null>(null);
|
||||
|
||||
async function commitDelete() {
|
||||
const target = confirmingDelete;
|
||||
if (!target) return;
|
||||
setConfirmingDelete(null);
|
||||
if (target.kind === "chat") {
|
||||
try {
|
||||
await handleDeleteThread(target.item);
|
||||
} catch (err) {
|
||||
toast.error("Failed to delete chat", {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (target.run.status === "running") {
|
||||
toast.error("Cannot delete a running training run");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteTrainingRun(target.run.id);
|
||||
if (selectedHistoryRunId === target.run.id) {
|
||||
setSelectedHistoryRunId(null);
|
||||
}
|
||||
emitTrainingRunDeleted(target.run.id);
|
||||
} catch (err) {
|
||||
toast.error("Failed to delete run", {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar
|
||||
|
|
@ -220,7 +348,7 @@ export function AppSidebar() {
|
|||
variant="sidebar"
|
||||
className="font-heading group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-white dark:group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-background"
|
||||
>
|
||||
<SidebarHeader className="pl-[17px] pr-3 pt-[12px] pb-[12px] group-data-[collapsible=icon]:px-0">
|
||||
<SidebarHeader className="pl-[17px] pr-3 pt-[12px] pb-[8px] group-data-[collapsible=icon]:px-0">
|
||||
{/* Expanded: compact logo + close toggle */}
|
||||
<div className="flex items-center justify-between gap-[8.5px] group-data-[collapsible=icon]:hidden">
|
||||
<Link
|
||||
|
|
@ -246,10 +374,7 @@ export function AppSidebar() {
|
|||
<span className="font-heading text-[21px] font-semibold tracking-[-0.01em] dark:tracking-[0.02em] leading-none text-black dark:text-white">
|
||||
unsloth
|
||||
</span>
|
||||
<span
|
||||
style={{ fontFamily: '"Inter Variable", ui-sans-serif, system-ui, sans-serif' }}
|
||||
className="ml-0.5 inline-flex items-center justify-center rounded-full border border-[#e0ded6] px-[5px] py-[2px] text-[8px] font-medium leading-none tracking-[0.04em] text-[#62605a] antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:border-[#3a3c3f] dark:text-[#9d9fa5] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]"
|
||||
>
|
||||
<span className="nav-badge ml-0.5 inline-flex items-center justify-center rounded-full border border-nav-beta-border px-[5px] pt-[3px] pb-[2px] text-[8px] font-medium leading-none tracking-[0.04em] text-nav-fg-muted antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]">
|
||||
BETA
|
||||
</span>
|
||||
</Link>
|
||||
|
|
@ -259,13 +384,17 @@ export function AppSidebar() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={togglePinned}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-[10px] text-[#8f8f8f] dark:text-[#5c5c5c] transition-colors hover:bg-[#f0f0f0] dark:hover:bg-[#2a2c2f] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="inline-flex h-[35px] w-[32px] items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-[18px]" />
|
||||
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
|
||||
</button>
|
||||
</TooltipPrimitive.Trigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
className="tooltip-compact"
|
||||
>
|
||||
Close sidebar
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -274,19 +403,23 @@ export function AppSidebar() {
|
|||
|
||||
{/* Collapsed: panel icon doubles as expand trigger */}
|
||||
{!isMobile && (
|
||||
<div className="hidden group-data-[collapsible=icon]:flex h-[34px] items-center justify-center w-full">
|
||||
<div className="hidden group-data-[collapsible=icon]:flex h-[35px] items-center justify-center w-full">
|
||||
<Tooltip>
|
||||
<TooltipPrimitive.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePinned}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-[10px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#f0f0f0] dark:hover:bg-[#2a2c2f] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="inline-flex h-[35px] w-[32px] items-center justify-center rounded-[10px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-[18px]" />
|
||||
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
|
||||
</button>
|
||||
</TooltipPrimitive.Trigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
className="tooltip-compact"
|
||||
>
|
||||
Open sidebar
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -294,7 +427,7 @@ export function AppSidebar() {
|
|||
)}
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-2 pt-[10px] pb-[14px] shrink-0">
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-2 pt-[9px] pb-[8px] shrink-0">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<NavItem
|
||||
|
|
@ -337,66 +470,68 @@ export function AppSidebar() {
|
|||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarContent className="gap-0 overflow-y-auto overscroll-contain min-h-0">
|
||||
{/* Navigate (no header) */}
|
||||
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:px-0 px-2 pt-[10px] pb-[14px]">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<NavItem
|
||||
icon={GemIcon}
|
||||
label="Train"
|
||||
active={pathname === "/studio" || pathname.startsWith("/studio/")}
|
||||
disabled={chatOnly}
|
||||
onClick={() => {
|
||||
if (chatOnly) return;
|
||||
navigate({ to: "/studio" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:px-0 px-2 pt-[9px] pb-[20px] shrink-0">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<NavItem
|
||||
icon={TestTubeOutlineIcon}
|
||||
label="Train"
|
||||
active={pathname === "/studio" || pathname.startsWith("/studio/")}
|
||||
disabled={chatOnly}
|
||||
onClick={() => {
|
||||
if (chatOnly) return;
|
||||
navigate({ to: "/studio" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
|
||||
<NavItem
|
||||
icon={ChefHatIcon}
|
||||
label="Recipes"
|
||||
active={isRecipesRoute}
|
||||
onClick={() => {
|
||||
navigate({ to: "/data-recipes" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
<NavItem
|
||||
icon={ChefHatIcon}
|
||||
label="Recipes"
|
||||
active={isRecipesRoute}
|
||||
onClick={() => {
|
||||
navigate({ to: "/data-recipes" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
|
||||
<NavItem
|
||||
icon={Download03Icon}
|
||||
label="Export"
|
||||
active={pathname === "/export" || pathname.startsWith("/export/")}
|
||||
disabled={chatOnly}
|
||||
onClick={() => {
|
||||
if (chatOnly) return;
|
||||
navigate({ to: "/export" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<NavItem
|
||||
icon={DownloadSquare01Icon}
|
||||
label="Export"
|
||||
active={pathname === "/export" || pathname.startsWith("/export/")}
|
||||
disabled={chatOnly}
|
||||
onClick={() => {
|
||||
if (chatOnly) return;
|
||||
navigate({ to: "/export" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarContent ref={scrollRef} className="gap-0 overflow-y-auto overscroll-contain min-h-0">
|
||||
{/* Recent Chats — hide on Studio only (Eyera fac13); chatOpen = ec695 clickability */}
|
||||
{!isStudioRoute && chatItems.length > 0 && (
|
||||
<Collapsible open={chatOpen} onOpenChange={setChatOpen} asChild>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden px-2 py-0">
|
||||
<SidebarGroupLabel className="pt-2 pb-1.5 pl-2.5 pr-2 text-[12.5px]! font-normal normal-case tracking-normal text-[#62605a] dark:text-[#9d9fa5] focus-visible:ring-0! focus-visible:outline-none" asChild>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
|
||||
<SidebarGroupLabel className={cn("sidebar-sticky-label", scrolled && "is-scrolled")} asChild>
|
||||
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
|
||||
Recents
|
||||
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarGroupContent className="px-2">
|
||||
<SidebarMenu>
|
||||
{chatItems.map((item) => (
|
||||
<SidebarMenuItem key={item.id} className="group/recent-item relative">
|
||||
<SidebarMenuButton
|
||||
data-testid="recent-thread"
|
||||
data-thread-type={item.type}
|
||||
data-thread-id={item.id}
|
||||
isActive={activeThreadId === item.id}
|
||||
className="h-[32px] rounded-[10px] pl-2.5 pr-7 text-[14px] leading-[18px] tracking-[0.01em] font-medium text-[#383835] dark:text-[#c7c7c4] hover:bg-[#f0f0f0]! dark:hover:bg-[#2a2c2f]! hover:text-black! dark:hover:text-white! data-active:bg-[#f0f0f0]! dark:data-active:bg-[#2a2c2f]! data-active:text-black! dark:data-active:text-white!"
|
||||
className="sidebar-nav-btn h-[32px] rounded-[10px] pl-2.5 pr-2.5 group-hover/recent-item:pr-10 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-10 text-[14.5px] leading-[19px] tracking-nav font-medium"
|
||||
onClick={() => {
|
||||
navigate({
|
||||
to: "/chat",
|
||||
|
|
@ -410,17 +545,38 @@ export function AppSidebar() {
|
|||
>
|
||||
<span className="truncate">{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteThread(item);
|
||||
}}
|
||||
title="Delete"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 flex size-5 scale-90 items-center justify-center rounded-[10px] text-sidebar-foreground/55 opacity-0 transition-all duration-150 hover:bg-destructive/12 hover:text-destructive group-hover/recent-item:scale-100 group-hover/recent-item:opacity-100"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Chat options"
|
||||
className="sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
|
||||
>
|
||||
<span className="sidebar-row-action-glyph">
|
||||
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0"
|
||||
>
|
||||
<DropdownMenuItem onSelect={() => openRenameChat(item)}>
|
||||
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Rename</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => setConfirmingDelete({ kind: "chat", item })}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
|
|
@ -433,15 +589,15 @@ export function AppSidebar() {
|
|||
{/* Recent Runs */}
|
||||
{isStudioRoute && runItems.length > 0 && !chatOnly && (
|
||||
<Collapsible open={runsOpen} onOpenChange={setRunsOpen} asChild>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden px-2 py-0">
|
||||
<SidebarGroupLabel className="pt-2 pb-1.5 pl-2.5 pr-2 text-[12.5px]! font-normal normal-case tracking-normal text-[#62605a] dark:text-[#9d9fa5] focus-visible:ring-0! focus-visible:outline-none" asChild>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
|
||||
<SidebarGroupLabel className={cn("sidebar-sticky-label", scrolled && "is-scrolled")} asChild>
|
||||
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
|
||||
Recents
|
||||
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarGroupContent className="px-2">
|
||||
<SidebarMenu>
|
||||
{runItems.map((run) => {
|
||||
const isActiveRun =
|
||||
|
|
@ -453,7 +609,7 @@ export function AppSidebar() {
|
|||
>
|
||||
<SidebarMenuButton
|
||||
isActive={isActiveRun}
|
||||
className="h-auto flex-col items-start gap-0.5 py-1.5 rounded-[10px] pl-2.5 pr-7 text-[14px] tracking-[0.01em] font-medium text-[#383835] dark:text-[#c7c7c4] hover:bg-[#f0f0f0]! dark:hover:bg-[#2a2c2f]! hover:text-black! dark:hover:text-white! data-active:bg-[#f0f0f0]! dark:data-active:bg-[#2a2c2f]! data-active:text-black! dark:data-active:text-white!"
|
||||
className="sidebar-nav-btn h-auto flex-col items-start gap-0.5 py-[5px] rounded-[10px] pl-2.5 pr-7 text-[14.5px] tracking-nav font-medium"
|
||||
onClick={() => {
|
||||
setSelectedHistoryRunId(run.id);
|
||||
closeMobileIfOpen();
|
||||
|
|
@ -468,7 +624,7 @@ export function AppSidebar() {
|
|||
aria-hidden
|
||||
/>
|
||||
<span className="truncate">
|
||||
{run.model_name}
|
||||
{run.display_name ?? run.model_name}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">
|
||||
{formatRelativeShort(run.started_at)}
|
||||
|
|
@ -478,25 +634,41 @@ export function AppSidebar() {
|
|||
{run.dataset_name}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await deleteTrainingRun(run.id);
|
||||
if (selectedHistoryRunId === run.id) {
|
||||
setSelectedHistoryRunId(null);
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Run options"
|
||||
className="sidebar-row-action group-hover/run-item:opacity-100 group-hover/run-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
|
||||
>
|
||||
<span className="sidebar-row-action-glyph">
|
||||
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0"
|
||||
>
|
||||
<DropdownMenuItem onSelect={() => openRenameRun(run)}>
|
||||
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Rename</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={run.status === "running"}
|
||||
onSelect={() =>
|
||||
setConfirmingDelete({ kind: "run", run })
|
||||
}
|
||||
await refreshRuns();
|
||||
} catch {
|
||||
// ignore — next refresh will reconcile
|
||||
}
|
||||
}}
|
||||
title="Delete"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 flex size-5 scale-90 items-center justify-center rounded-[10px] text-sidebar-foreground/55 opacity-0 transition-all duration-150 hover:bg-destructive/12 hover:text-destructive group-hover/run-item:scale-100 group-hover/run-item:opacity-100"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
|
||||
</button>
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
|
|
@ -516,7 +688,7 @@ export function AppSidebar() {
|
|||
<SidebarMenuButton
|
||||
size="lg"
|
||||
aria-label={`${displayTitle} account menu`}
|
||||
className="!h-[50px] gap-[8px] rounded-[10px] text-[#383835] dark:text-[#c7c7c4] hover:bg-[#f0f0f0]! dark:hover:bg-[#2a2c2f]! hover:text-black! dark:hover:text-white! data-[state=open]:bg-[#f0f0f0]! dark:data-[state=open]:bg-[#2a2c2f]! data-[state=open]:text-black! dark:data-[state=open]:text-white!"
|
||||
className="sidebar-nav-btn !h-[50px] gap-[8px] px-2 py-[9px] rounded-[10px]"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<UserAvatar
|
||||
|
|
@ -527,8 +699,8 @@ export function AppSidebar() {
|
|||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate font-heading text-[13px] tracking-[0.02em] font-semibold text-[#383835] dark:text-[#c7c7c4]">{displayTitle}</span>
|
||||
<span className="truncate text-[11px] tracking-[0.01em] text-muted-foreground">Unsloth</span>
|
||||
<span className="truncate font-heading text-[13.5px] tracking-[0.025em] dark:tracking-[0.04em] font-semibold text-nav-fg">{displayTitle}</span>
|
||||
<span className="truncate text-[11.5px] tracking-nav text-muted-foreground">Unsloth</span>
|
||||
</div>
|
||||
<ChevronsUpDown strokeWidth={1.25} className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarMenuButton>
|
||||
|
|
@ -536,13 +708,13 @@ export function AppSidebar() {
|
|||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-[15rem] py-2.5 font-heading [&_[data-slot=dropdown-menu-group]]:flex [&_[data-slot=dropdown-menu-group]]:flex-col [&_[data-slot=dropdown-menu-group]]:gap-px [&_[data-slot=dropdown-menu-item]]:h-[32px] [&_[data-slot=dropdown-menu-item]]:px-2.5! [&_[data-slot=dropdown-menu-item]]:py-0! [&_[data-slot=dropdown-menu-item]]:gap-[8.5px]! [&_[data-slot=dropdown-menu-item]]:rounded-[10px] [&_[data-slot=dropdown-menu-item]]:font-medium [&_[data-slot=dropdown-menu-item]]:text-[14px] [&_[data-slot=dropdown-menu-item]]:leading-[18px] [&_[data-slot=dropdown-menu-item]]:tracking-[0.01em] [&_[data-slot=dropdown-menu-item]]:text-[#383835] dark:[&_[data-slot=dropdown-menu-item]]:text-[#c7c7c4] [&_[data-slot=dropdown-menu-item]_svg]:!size-[18px] [&_[data-slot=dropdown-menu-item]_svg]:shrink-0 [&_[data-slot=dropdown-menu-item]:focus]:bg-[#f0f0f0] dark:[&_[data-slot=dropdown-menu-item]:focus]:bg-[#2a2c2f] [&_[data-slot=dropdown-menu-item]:focus]:text-black dark:[&_[data-slot=dropdown-menu-item]:focus]:text-white [&_[data-slot=dropdown-menu-item]:focus_*]:text-black! dark:[&_[data-slot=dropdown-menu-item]:focus_*]:text-white!"
|
||||
className="app-user-menu menu-soft-surface-up ring-0 w-[15rem] py-2.5 font-heading rounded-[14px] border-0"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => useSettingsDialogStore.getState().openDialog()}
|
||||
>
|
||||
<HugeiconsIcon icon={Settings02Icon} strokeWidth={1.75} className="size-[18px]" />
|
||||
<HugeiconsIcon icon={Settings02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Settings</span>
|
||||
<DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -559,7 +731,7 @@ export function AppSidebar() {
|
|||
ref={anchorRef as React.Ref<HTMLDivElement>}
|
||||
onSelect={(e) => { e.preventDefault(); toggleTheme(); }}
|
||||
>
|
||||
{isDark ? <Sun strokeWidth={1.75} className="size-[18px]" /> : <Moon strokeWidth={1.75} className="size-[18px]" />}
|
||||
{isDark ? <Sun strokeWidth={1.75} className="size-icon" /> : <Moon strokeWidth={1.75} className="size-icon" />}
|
||||
<span>{isDark ? "Light Mode" : "Dark Mode"}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
|
|
@ -574,7 +746,7 @@ export function AppSidebar() {
|
|||
);
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-[18px]" />
|
||||
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Guided Tour</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
|
@ -582,11 +754,11 @@ export function AppSidebar() {
|
|||
<DropdownMenuItem
|
||||
onSelect={() => useSettingsDialogStore.getState().openDialog("about")}
|
||||
>
|
||||
<HugeiconsIcon icon={HelpCircleIcon} strokeWidth={1.75} className="size-[18px]" />
|
||||
<HugeiconsIcon icon={HelpCircleIcon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Help</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setShutdownOpen(true)}>
|
||||
<HugeiconsIcon icon={PowerIcon} strokeWidth={1.75} className="size-[18px]" />
|
||||
<HugeiconsIcon icon={PowerIcon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Shutdown</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
|
@ -601,6 +773,96 @@ export function AppSidebar() {
|
|||
onOpenChange={setShutdownOpen}
|
||||
onAfterShutdown={removeTrainingUnloadGuard}
|
||||
/>
|
||||
<Dialog
|
||||
open={confirmingDelete !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmingDelete(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="menu-flat-destructive corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{confirmingDelete?.kind === "run"
|
||||
? "Delete training run"
|
||||
: "Delete chat"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{confirmingDelete?.kind === "run" ? (
|
||||
<>
|
||||
Are you sure you want to delete this run{" "}
|
||||
<em>{confirmingDelete.run.display_name ?? confirmingDelete.run.model_name}</em>?
|
||||
</>
|
||||
) : confirmingDelete?.kind === "chat" ? (
|
||||
<>
|
||||
Are you sure you want to delete this chat{" "}
|
||||
<em>{confirmingDelete.item.title}</em>?
|
||||
</>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmingDelete(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => void commitDelete()}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={renamingTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRenamingTarget(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{renamingTarget?.kind === "run" ? "Rename run" : "Rename chat"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={renameDraft}
|
||||
onChange={(event) => setRenameDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void commitRename();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
maxLength={120}
|
||||
placeholder={renamingTarget?.kind === "run" ? "Run name" : "Chat title"}
|
||||
aria-label={renamingTarget?.kind === "run" ? "Run name" : "Chat title"}
|
||||
className="focus-visible:border-input focus-visible:ring-0"
|
||||
/>
|
||||
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setRenamingTarget(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void commitRename()}
|
||||
disabled={!renameDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ const AttachmentUI: FC = () => {
|
|||
</AttachmentPreviewDialog>
|
||||
{isComposer && <AttachmentRemove />}
|
||||
</AttachmentPrimitive.Root>
|
||||
<TooltipContent side="top">
|
||||
<TooltipContent side="top" className="tooltip-compact">
|
||||
<AttachmentPrimitive.Name />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
|||
import { preprocessLaTeX } from "@/lib/latex";
|
||||
import { openLink } from "@/lib/open-link";
|
||||
import { INTERNAL, useMessagePartText } from "@assistant-ui/react";
|
||||
import { Copy02Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { createCodePlugin } from "./code-plugin";
|
||||
import { createMathPlugin } from "@streamdown/math";
|
||||
|
|
@ -50,9 +50,9 @@ const COPY_RESET_MS = 2000;
|
|||
const MERMAID_SOURCE_RE = /```mermaid\s*([\s\S]*?)```/i;
|
||||
const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
|
||||
const ACTION_PANEL_CLASS =
|
||||
"pointer-events-auto flex shrink-0 items-center gap-2 rounded-md border border-sidebar bg-sidebar/80 px-1.5 py-1 supports-[backdrop-filter]:bg-sidebar/70 supports-[backdrop-filter]:backdrop-blur dark:border-white/10 dark:bg-code-block dark:supports-[backdrop-filter]:bg-code-block";
|
||||
"pointer-events-auto flex shrink-0 items-center gap-1";
|
||||
const ACTION_BUTTON_CLASS =
|
||||
"cursor-pointer p-1 text-muted-foreground transition-all hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50";
|
||||
"flex size-8 cursor-pointer items-center justify-center rounded-[10px] text-chat-icon-fg transition-all hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
type CodeFence = {
|
||||
language: string | null;
|
||||
|
|
@ -289,8 +289,9 @@ function MermaidCopyButton({ source }: { source: string }) {
|
|||
}}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={copied ? Tick02Icon : Copy02Icon}
|
||||
className="size-5"
|
||||
icon={copied ? Tick02Icon : Copy01Icon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
|
@ -308,7 +309,7 @@ function CodeBlockActions({
|
|||
const { copied, showCopied } = useCopiedState();
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute top-3.5 right-3 z-20 flex items-center justify-end">
|
||||
<div className="pointer-events-none absolute top-3 right-3 z-20 flex items-center justify-end">
|
||||
<div className={ACTION_PANEL_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -323,8 +324,9 @@ function CodeBlockActions({
|
|||
}}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={copied ? Tick02Icon : Copy02Icon}
|
||||
className="size-3.5"
|
||||
icon={copied ? Tick02Icon : Copy01Icon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -336,7 +338,7 @@ function CodeBlockActions({
|
|||
downloadTextFile(getCodeFilename(language), source);
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
<DownloadIcon className="size-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export const MessageTiming: FC<{
|
|||
data-slot="message-timing-trigger"
|
||||
aria-label="Message timing"
|
||||
className={cn(
|
||||
"flex items-center rounded-md p-1 font-mono text-muted-foreground text-xs tabular-nums transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
"flex items-center rounded-[10px] p-1 font-mono text-chat-icon-fg text-[13px] tabular-nums transition-colors hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
@ -62,7 +62,8 @@ export const MessageTiming: FC<{
|
|||
side={side}
|
||||
sideOffset={8}
|
||||
data-slot="message-timing-popover"
|
||||
className="[&_span>svg]:hidden! rounded-lg border bg-popover px-3 py-2 text-popover-foreground shadow-md"
|
||||
variant="rich"
|
||||
className="[&_span>svg]:hidden!"
|
||||
>
|
||||
<div className="grid min-w-40 gap-1.5 text-xs">
|
||||
{st ? (
|
||||
|
|
|
|||
|
|
@ -78,9 +78,9 @@ function ModelSelectorTrigger({
|
|||
className={cn(
|
||||
"flex min-w-0 items-center gap-2 transition-colors",
|
||||
variant === "outline" &&
|
||||
"rounded-[8px] border border-border/60 hover:bg-[#ececec] dark:hover:bg-[#2e3035]",
|
||||
variant === "ghost" && "rounded-[8px] hover:bg-[#ececec] dark:hover:bg-[#2e3035]",
|
||||
variant === "muted" && "rounded-[8px] bg-muted hover:bg-muted/80",
|
||||
"rounded-[10px] border border-border/60 hover:bg-[#ececec] dark:hover:bg-[#2d2e32]",
|
||||
variant === "ghost" && "rounded-[10px] hover:bg-[#ececec] dark:hover:bg-[#2d2e32]",
|
||||
variant === "muted" && "rounded-[10px] bg-muted hover:bg-muted/80",
|
||||
size === "sm" && "h-8 px-3 text-xs",
|
||||
size === "default" && "h-9 px-3.5 text-sm",
|
||||
size === "lg" && "h-10 px-4 text-sm",
|
||||
|
|
@ -145,7 +145,7 @@ function ModelSelectorContent({
|
|||
align="start"
|
||||
data-tour={dataTour}
|
||||
className={cn(
|
||||
"w-[min(440px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 p-2",
|
||||
"menu-soft-surface ring-0 w-[min(440px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 p-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -175,7 +175,10 @@ function ModelRow({
|
|||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>{content}</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs break-all">
|
||||
<TooltipContent
|
||||
side="left"
|
||||
className="tooltip-compact max-w-xs break-all"
|
||||
>
|
||||
{label}
|
||||
<span className="block text-[10px] mt-1">{vramTooltipText}</span>
|
||||
</TooltipContent>
|
||||
|
|
@ -187,7 +190,10 @@ function ModelRow({
|
|||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>{content}</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs break-all">
|
||||
<TooltipContent
|
||||
side="left"
|
||||
className="tooltip-compact max-w-xs break-all"
|
||||
>
|
||||
{tooltipText}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ function Source({
|
|||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"cursor-pointer outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"rounded-full cursor-pointer outline-none hover:bg-chat-icon-bg-hover! hover:text-chat-icon-fg-hover! focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
@ -137,7 +137,7 @@ const SourceBadge: FC<{ source: SourceData }> = ({ source }) => {
|
|||
const displayTitle = source.title || domain;
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={300} closeDelay={100}>
|
||||
<HoverCard openDelay={0} closeDelay={0}>
|
||||
<HoverCardTrigger asChild>
|
||||
<span className="inline-block">
|
||||
<Source href={source.url}>
|
||||
|
|
@ -146,16 +146,21 @@ const SourceBadge: FC<{ source: SourceData }> = ({ source }) => {
|
|||
</Source>
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="top" align="start" className="w-72 p-3">
|
||||
<HoverCardContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="!bg-black !text-white !w-72 !p-3 !rounded-2xl !shadow-md !ring-0 !duration-0"
|
||||
style={{ animation: "none" }}
|
||||
>
|
||||
<div className="flex gap-2.5">
|
||||
<SourceIcon url={source.url} size={4} className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-semibold leading-tight truncate">
|
||||
{source.title || domain}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{domain}</p>
|
||||
<p className="text-xs text-white/60 truncate">{domain}</p>
|
||||
{source.description && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
|
||||
<p className="text-xs text-white/70 leading-relaxed line-clamp-3">
|
||||
{source.description}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -245,7 +250,7 @@ const SourcesGroup: FC = () => {
|
|||
const hiddenCount = sources.length - (visibleCount ?? sources.length);
|
||||
|
||||
return (
|
||||
<div className="relative mt-2">
|
||||
<div className="relative mt-2 mb-3">
|
||||
{/* Hidden measurement container — renders all badges to measure row positions */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
@ -273,7 +278,7 @@ const SourcesGroup: FC = () => {
|
|||
onClick={() => setExpanded(true)}
|
||||
className={cn(
|
||||
badgeVariants({ variant: "outline", size: "default" }),
|
||||
"cursor-pointer text-muted-foreground hover:text-foreground",
|
||||
"rounded-full cursor-pointer text-muted-foreground hover:bg-chat-icon-bg-hover! hover:text-chat-icon-fg-hover!",
|
||||
)}
|
||||
>
|
||||
+{hiddenCount} more
|
||||
|
|
@ -285,7 +290,7 @@ const SourcesGroup: FC = () => {
|
|||
onClick={() => setExpanded(false)}
|
||||
className={cn(
|
||||
badgeVariants({ variant: "outline", size: "default" }),
|
||||
"cursor-pointer text-muted-foreground hover:text-foreground",
|
||||
"rounded-full cursor-pointer text-muted-foreground hover:bg-chat-icon-bg-hover! hover:text-chat-icon-fg-hover!",
|
||||
)}
|
||||
>
|
||||
Show less
|
||||
|
|
|
|||
|
|
@ -51,13 +51,12 @@ import {
|
|||
useAuiEvent,
|
||||
useAuiState,
|
||||
} from "@assistant-ui/react";
|
||||
import { flushResourcesSync } from "@assistant-ui/tap";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
GlobeIcon,
|
||||
HeadphonesIcon,
|
||||
|
|
@ -66,15 +65,16 @@ import {
|
|||
LoaderIcon,
|
||||
MicIcon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
RefreshCwIcon,
|
||||
SquareIcon,
|
||||
TerminalIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Copy01Icon, Delete02Icon, Edit03Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type CompositionEvent,
|
||||
type FC,
|
||||
type FormEvent,
|
||||
useCallback,
|
||||
|
|
@ -108,9 +108,9 @@ export const Thread: FC<{
|
|||
<ThreadPrimitive.Root
|
||||
className="aui-root aui-thread-root @container relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden"
|
||||
style={{
|
||||
["--thread-max-width" as string]: "44rem",
|
||||
["--thread-max-width" as string]: "48rem",
|
||||
["--thread-content-max-width" as string]:
|
||||
"calc(var(--thread-max-width) - 2.5rem)",
|
||||
"calc(var(--thread-max-width) - 1.5rem)",
|
||||
}}
|
||||
>
|
||||
<IntentAwareScrollProvider value={autoScrollContext}>
|
||||
|
|
@ -121,7 +121,7 @@ export const Thread: FC<{
|
|||
scrollToBottomOnInitialize={false}
|
||||
scrollToBottomOnThreadSwitch={false}
|
||||
className={cn(
|
||||
"aui-thread-viewport relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-x-auto overflow-y-auto scroll-smooth px-5",
|
||||
"aui-thread-viewport aui-stream-viewport relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-x-auto overflow-y-auto scroll-smooth px-5",
|
||||
hideComposer ? "pt-4" : "pt-[48px]",
|
||||
)}
|
||||
>
|
||||
|
|
@ -164,7 +164,7 @@ export const Thread: FC<{
|
|||
|
||||
{!hideComposer && (
|
||||
<AuiIf condition={({ thread }) => hideWelcome || !thread.isEmpty}>
|
||||
<div className="aui-thread-composer-dock pointer-events-none absolute bottom-0 left-0 right-0 md:right-2 z-20">
|
||||
<div className="aui-thread-composer-dock pointer-events-none absolute bottom-0 left-0 right-0 md:right-[10px] z-20">
|
||||
<div
|
||||
aria-hidden={true}
|
||||
className="absolute inset-x-0 bottom-0 top-[10px] bg-background"
|
||||
|
|
@ -173,8 +173,8 @@ export const Thread: FC<{
|
|||
<div className="pointer-events-auto mx-auto w-full max-w-(--thread-max-width)">
|
||||
<ComposerAnimated disabled={isComposerAttachPending} />
|
||||
</div>
|
||||
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">
|
||||
LLMs can make mistakes. Double-check all responses.
|
||||
<p className="composer-footer-note">
|
||||
LLMs can make mistakes. Double-check responses.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -204,7 +204,7 @@ const ThreadScrollToBottom: FC = () => {
|
|||
isAtBottom && "invisible pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
<ArrowDownIcon strokeWidth={1.75} className="size-icon" />
|
||||
</TooltipIconButton>
|
||||
);
|
||||
};
|
||||
|
|
@ -253,14 +253,9 @@ const GeneratingSpinner: FC = () => {
|
|||
const ComposerAnimated: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
||||
return (
|
||||
<div className="relative mx-auto min-w-0 w-full max-w-(--thread-max-width)">
|
||||
<motion.div
|
||||
layout={true}
|
||||
layoutId="composer"
|
||||
transition={{ type: "spring", bounce: 0.15, duration: 0.5 }}
|
||||
className="relative z-10 w-full"
|
||||
>
|
||||
<div className="relative z-10 w-full">
|
||||
<Composer disabled={disabled} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -290,13 +285,15 @@ const PendingAudioChip: FC = () => {
|
|||
};
|
||||
|
||||
const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
||||
const { inputProps, isComposing, isComposingRef } = useImeComposerInputHandlers();
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
if (disabled) {
|
||||
if (disabled || isComposingRef.current) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[disabled],
|
||||
[disabled, isComposingRef],
|
||||
);
|
||||
|
||||
const composerContent = (
|
||||
|
|
@ -306,14 +303,18 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
|||
<ToolStatusDisplay />
|
||||
<ComposerPrimitive.Input
|
||||
placeholder="Send a message..."
|
||||
className="aui-composer-input mb-1 min-h-12 w-full resize-none overflow-y-auto bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0"
|
||||
className="aui-composer-input composer-input"
|
||||
minRows={1}
|
||||
maxRows={6}
|
||||
autoFocus={!disabled}
|
||||
disabled={disabled}
|
||||
aria-label="Message input"
|
||||
{...inputProps}
|
||||
/>
|
||||
<ComposerAction
|
||||
disabled={disabled || isComposing}
|
||||
blockSend={() => isComposingRef.current}
|
||||
/>
|
||||
<ComposerAction disabled={disabled} />
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
@ -326,11 +327,11 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
|||
{isTauri ? (
|
||||
// Phase 1 native model drops own Tauri local-path drops. Restore browser
|
||||
// attachment drops in Tauri when Phase 1d adds attachment-token bridging.
|
||||
<div className="aui-composer-attachment-dropzone chat-composer-surface flex w-full flex-col rounded-3xl bg-background dark:bg-card px-1 pt-2 outline-none transition-shadow">
|
||||
<div className="aui-composer-attachment-dropzone chat-composer-surface">
|
||||
{composerContent}
|
||||
</div>
|
||||
) : (
|
||||
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone chat-composer-surface flex w-full flex-col rounded-3xl bg-background dark:bg-card px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:bg-accent/50">
|
||||
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone chat-composer-surface data-[dragging=true]:border-ring data-[dragging=true]:bg-accent/50">
|
||||
{composerContent}
|
||||
</ComposerPrimitive.AttachmentDropzone>
|
||||
)}
|
||||
|
|
@ -338,6 +339,64 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
|||
);
|
||||
};
|
||||
|
||||
function isNativeComposing(event: Event) {
|
||||
return "isComposing" in event && (event as InputEvent).isComposing === true;
|
||||
}
|
||||
|
||||
function useImeComposerInputHandlers() {
|
||||
const aui = useAui();
|
||||
const composingRef = useRef(false);
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
|
||||
const setCompositionState = useCallback((next: boolean) => {
|
||||
composingRef.current = next;
|
||||
setIsComposing(next);
|
||||
}, []);
|
||||
|
||||
const setComposerText = useCallback(
|
||||
(value: string) => {
|
||||
const composer = aui.composer();
|
||||
if (!composer.getState().isEditing) {
|
||||
return;
|
||||
}
|
||||
flushResourcesSync(() => {
|
||||
composer.setText(value);
|
||||
});
|
||||
},
|
||||
[aui],
|
||||
);
|
||||
|
||||
const onCompositionStart = useCallback(() => {
|
||||
setCompositionState(true);
|
||||
}, [setCompositionState]);
|
||||
|
||||
const onCompositionEnd = useCallback(
|
||||
(e: CompositionEvent<HTMLTextAreaElement>) => {
|
||||
setCompositionState(false);
|
||||
setComposerText(e.currentTarget.value);
|
||||
},
|
||||
[setComposerText, setCompositionState],
|
||||
);
|
||||
|
||||
const onChange = useCallback(
|
||||
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setCompositionState(isNativeComposing(e.nativeEvent));
|
||||
setComposerText(e.target.value);
|
||||
},
|
||||
[setComposerText, setCompositionState],
|
||||
);
|
||||
|
||||
return {
|
||||
inputProps: {
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
onChange,
|
||||
},
|
||||
isComposing,
|
||||
isComposingRef: composingRef,
|
||||
};
|
||||
}
|
||||
|
||||
const ComposerAudioUpload: FC = () => {
|
||||
const audioInputRef = useRef<HTMLInputElement>(null);
|
||||
const setPendingAudio = useChatRuntimeStore((s) => s.setPendingAudio);
|
||||
|
|
@ -455,14 +514,8 @@ const ReasoningToggle: FC = () => {
|
|||
setReasoningEnabled(next);
|
||||
applyQwenThinkingParams(next);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: reasoningEnabled
|
||||
? "bg-primary/10 text-primary hover:bg-primary/20"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
|
||||
)}
|
||||
className="composer-pill-btn"
|
||||
data-active={reasoningEnabled && !disabled ? "true" : "false"}
|
||||
aria-label={reasoningEnabled ? "Disable thinking" : "Enable thinking"}
|
||||
>
|
||||
{reasoningEnabled && !disabled ? (
|
||||
|
|
@ -527,14 +580,8 @@ const WebSearchToggle: FC = () => {
|
|||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setToolsEnabled(!toolsEnabled)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: toolsEnabled
|
||||
? "bg-primary/10 text-primary hover:bg-primary/20"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
|
||||
)}
|
||||
className="composer-pill-btn"
|
||||
data-active={toolsEnabled && !disabled ? "true" : "false"}
|
||||
aria-label={toolsEnabled ? "Disable web search" : "Enable web search"}
|
||||
>
|
||||
<GlobeIcon className="size-3.5" />
|
||||
|
|
@ -557,14 +604,8 @@ const CodeToolsToggle: FC = () => {
|
|||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: codeToolsEnabled
|
||||
? "bg-primary/10 text-primary hover:bg-primary/20"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
|
||||
)}
|
||||
className="composer-pill-btn"
|
||||
data-active={codeToolsEnabled && !disabled ? "true" : "false"}
|
||||
aria-label={
|
||||
codeToolsEnabled ? "Disable code execution" : "Enable code execution"
|
||||
}
|
||||
|
|
@ -633,9 +674,12 @@ const ToolStatusDisplay: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const ComposerAction: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
||||
const ComposerAction: FC<{ disabled?: boolean; blockSend?: () => boolean }> = ({
|
||||
disabled,
|
||||
blockSend,
|
||||
}) => {
|
||||
return (
|
||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||
<div className="aui-composer-action-wrapper composer-action-wrapper">
|
||||
<div className="flex items-center gap-1">
|
||||
<ComposerAddAttachment />
|
||||
<ComposerAudioUpload />
|
||||
|
|
@ -676,6 +720,11 @@ const ComposerAction: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
|||
variant="default"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
onClick={(event) => {
|
||||
if (blockSend?.()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="aui-composer-send size-8 rounded-full"
|
||||
aria-label="Send message"
|
||||
>
|
||||
|
|
@ -725,10 +774,10 @@ const GeneratingIndicator: FC = () => {
|
|||
const AssistantMessage: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto min-w-0 w-full max-w-(--thread-content-max-width) animate-in py-0.5 text-[15.5px] font-[450] duration-150"
|
||||
className="aui-assistant-message-root relative mx-auto min-w-0 w-full max-w-(--thread-content-max-width) pt-0.5 pb-4 text-[15.5px] [font-weight:410] tracking-[0.01em] dark:tracking-[0.02em]"
|
||||
data-role="assistant"
|
||||
>
|
||||
<div className="aui-assistant-message-content wrap-break-word min-w-0 text-foreground leading-relaxed">
|
||||
<div className="aui-assistant-message-content wrap-break-word min-w-0 text-[#0d0d0d] dark:text-foreground leading-relaxed">
|
||||
<GeneratingIndicator />
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
|
|
@ -751,8 +800,8 @@ const AssistantMessage: FC = () => {
|
|||
<MessageError />
|
||||
</div>
|
||||
|
||||
<div className="aui-assistant-message-footer mt-1 flex">
|
||||
<BranchPicker />
|
||||
<div className="aui-assistant-message-footer mt-1.5 -ml-[var(--icon-btn-inset)] flex min-h-8">
|
||||
<BranchPicker className="mr-0.5" />
|
||||
<AssistantActionBar />
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
|
|
@ -789,9 +838,13 @@ const DeleteMessageButton: FC = () => {
|
|||
tooltip="Delete message"
|
||||
disabled={isRunning}
|
||||
onClick={handleDelete}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
className="text-chat-icon-fg hover:text-destructive"
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
<HugeiconsIcon
|
||||
icon={Delete02Icon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon"
|
||||
/>
|
||||
</TooltipIconButton>
|
||||
);
|
||||
};
|
||||
|
|
@ -817,7 +870,11 @@ const CopyButton: FC = () => {
|
|||
|
||||
return (
|
||||
<TooltipIconButton tooltip="Copy" onClick={handleCopy}>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
<HugeiconsIcon
|
||||
icon={copied ? Tick02Icon : Copy01Icon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon"
|
||||
/>
|
||||
</TooltipIconButton>
|
||||
);
|
||||
};
|
||||
|
|
@ -826,40 +883,39 @@ const AssistantActionBar: FC = () => {
|
|||
return (
|
||||
<ActionBarPrimitive.Root
|
||||
hideWhenRunning={true}
|
||||
autohide="always"
|
||||
autohideFloat="single-branch"
|
||||
className="aui-assistant-action-bar-root col-start-3 row-start-2 -ml-1 flex gap-1 text-muted-foreground data-floating:absolute"
|
||||
className="aui-assistant-action-bar-root col-start-3 row-start-2 flex items-center gap-1 text-chat-icon-fg [&_button:not([data-slot=message-timing-trigger])]:size-8 [&_button]:!rounded-[10px] [&_button:hover]:bg-chat-icon-bg-hover [&_button:hover]:text-chat-icon-fg-hover"
|
||||
>
|
||||
<CopyButton />
|
||||
<ActionBarPrimitive.Reload asChild={true}>
|
||||
<TooltipIconButton tooltip="Refresh">
|
||||
<RefreshCwIcon />
|
||||
<RefreshCwIcon strokeWidth={1.75} className="size-icon" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Reload>
|
||||
<DeleteMessageButton />
|
||||
<MessageTiming side="top" />
|
||||
<ActionBarMorePrimitive.Root>
|
||||
<ActionBarMorePrimitive.Trigger asChild={true}>
|
||||
<TooltipIconButton
|
||||
tooltip="More"
|
||||
className="data-[state=open]:bg-accent"
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarMorePrimitive.Trigger>
|
||||
<ActionBarMorePrimitive.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
>
|
||||
<ActionBarPrimitive.ExportMarkdown asChild={true}>
|
||||
<ActionBarMorePrimitive.Item className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
|
||||
<DownloadIcon className="size-4" />
|
||||
<DownloadIcon strokeWidth={1.75} className="size-icon" />
|
||||
Export as Markdown
|
||||
</ActionBarMorePrimitive.Item>
|
||||
</ActionBarPrimitive.ExportMarkdown>
|
||||
</ActionBarMorePrimitive.Content>
|
||||
</ActionBarMorePrimitive.Root>
|
||||
<MessageTiming side="top" className="h-8 px-2" />
|
||||
</ActionBarPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
|
@ -884,22 +940,21 @@ const UserMessageAudio: FC = () => {
|
|||
const UserMessage: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto flex w-full max-w-(--thread-content-max-width) animate-in flex-col items-end gap-y-2 pt-6 pb-0.5 text-[15.5px] font-[450] duration-150"
|
||||
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto flex w-full max-w-(--thread-content-max-width) animate-in flex-col items-end gap-y-2 pt-6 pb-4 text-[15.5px] [font-weight:410] tracking-[0.01em] dark:tracking-[0.02em] duration-150"
|
||||
data-role="user"
|
||||
>
|
||||
<UserMessageAttachments />
|
||||
<UserMessageAudio />
|
||||
|
||||
<div className="aui-user-message-content-wrapper flex max-w-[80%] min-w-0 flex-col items-end">
|
||||
<div className="aui-user-message-content wrap-break-word w-fit rounded-[16px] rounded-tr-[4px] bg-[#f5f5f5] px-4 py-2.5 text-foreground dark:bg-card">
|
||||
<div className="aui-user-message-content wrap-break-word w-fit rounded-[24px] bg-[#f5f5f5] px-4 py-2.5 text-[#0d0d0d] dark:text-foreground dark:bg-card">
|
||||
<MessagePrimitive.Parts />
|
||||
</div>
|
||||
<div className="mt-1 flex min-h-6">
|
||||
<div className="mt-1 -mr-[var(--icon-btn-inset)] flex min-h-8 items-center">
|
||||
<UserActionBar />
|
||||
<BranchPicker className="aui-user-branch-picker ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BranchPicker className="aui-user-branch-picker -mr-1 justify-end" />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
|
@ -908,12 +963,12 @@ const UserActionBar: FC = () => {
|
|||
return (
|
||||
<ActionBarPrimitive.Root
|
||||
autohide="always"
|
||||
className="aui-user-action-bar-root -mr-1 flex gap-1 text-muted-foreground"
|
||||
className="aui-user-action-bar-root flex gap-1 text-chat-icon-fg [&_button]:size-8 [&_button]:!rounded-[10px] [&_button:hover]:bg-chat-icon-bg-hover [&_button:hover]:text-chat-icon-fg-hover"
|
||||
>
|
||||
<CopyButton />
|
||||
<ActionBarPrimitive.Edit asChild={true}>
|
||||
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit">
|
||||
<PencilIcon />
|
||||
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Edit>
|
||||
<DeleteMessageButton />
|
||||
|
|
@ -923,6 +978,7 @@ const UserActionBar: FC = () => {
|
|||
|
||||
const EditComposer: FC = () => {
|
||||
const aui = useAui();
|
||||
const { inputProps, isComposingRef } = useImeComposerInputHandlers();
|
||||
const resendAfterCancelRef = useRef(false);
|
||||
|
||||
useAuiEvent("thread.runEnd", () => {
|
||||
|
|
@ -939,16 +995,22 @@ 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}
|
||||
{...inputProps}
|
||||
/>
|
||||
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">
|
||||
<ComposerPrimitive.Cancel asChild={true}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Button type="button" variant="ghost" size="sm">
|
||||
Cancel
|
||||
</Button>
|
||||
</ComposerPrimitive.Cancel>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
if (isComposingRef.current) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const newText = aui.composer().getState().text;
|
||||
const originalText = aui.message().getCopyText();
|
||||
|
||||
|
|
@ -981,23 +1043,31 @@ const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({
|
|||
<BranchPickerPrimitive.Root
|
||||
hideWhenSingleBranch={true}
|
||||
className={cn(
|
||||
"aui-branch-picker-root mr-2 -ml-2 inline-flex items-center text-muted-foreground text-xs",
|
||||
"aui-branch-picker-root inline-flex items-center text-chat-icon-fg text-[13px]",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<BranchPickerPrimitive.Previous asChild={true}>
|
||||
<TooltipIconButton tooltip="Previous">
|
||||
<ChevronLeftIcon />
|
||||
</TooltipIconButton>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous"
|
||||
className="aui-branch-chevron-btn"
|
||||
>
|
||||
<ChevronLeftIcon strokeWidth={1.25} className="size-[36px]" />
|
||||
</button>
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="aui-branch-picker-state font-medium">
|
||||
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
||||
<span className="aui-branch-picker-state font-mono text-[13px] tabular-nums">
|
||||
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next asChild={true}>
|
||||
<TooltipIconButton tooltip="Next">
|
||||
<ChevronRightIcon />
|
||||
</TooltipIconButton>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next"
|
||||
className="aui-branch-chevron-btn"
|
||||
>
|
||||
<ChevronRightIcon strokeWidth={1.25} className="size-[36px]" />
|
||||
</button>
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ export const TooltipIconButton = forwardRef<
|
|||
<span className="aui-sr-only sr-only">{tooltip}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side}>{tooltip}</TooltipContent>
|
||||
<TooltipContent side={side} className="tooltip-compact">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,6 +60,16 @@ const UPWARD_DETACH_THRESHOLD_PX = 2;
|
|||
// keeps the viewport pinned as long as content keeps arriving; settles
|
||||
// this long after the last change.
|
||||
const FOLLOW_SETTLE_MS = 600;
|
||||
// Maximum stabilizer compensation. The stabilizer is meant to absorb
|
||||
// sub-frame transients (~5-15px shiki re-renders, ~8px action-bar
|
||||
// reservation drift). Anything larger is almost certainly an intentional
|
||||
// content removal — message delete, regenerate's old-content clear,
|
||||
// reasoning-panel collapse — and should *not* be silently padded over,
|
||||
// which would leave persistent empty space below the last message.
|
||||
// Above this threshold we release the stabilizer immediately and let
|
||||
// the autoscroll re-pin to the new content height, which is the natural
|
||||
// behavior the user expects for those actions.
|
||||
const STABILIZER_MAX_PX = 64;
|
||||
|
||||
export type ScrollToBottom = (behavior?: ScrollBehavior) => void;
|
||||
|
||||
|
|
@ -202,6 +212,21 @@ export function useIntentAwareAutoScroll(): {
|
|||
return false;
|
||||
};
|
||||
|
||||
// Stabilizer state — see `stabilize` below for the full
|
||||
// explanation. Lives in this closure so it resets naturally
|
||||
// whenever the viewport remounts (Compare-pane swap, thread
|
||||
// switch with remount, etc.).
|
||||
let stabilizerPx = 0;
|
||||
let maxContentHeight = 0;
|
||||
|
||||
const releaseStabilizer = (): void => {
|
||||
if (stabilizerPx === 0) {
|
||||
return;
|
||||
}
|
||||
stabilizerPx = 0;
|
||||
el.style.removeProperty("--aui-scroll-stabilizer");
|
||||
};
|
||||
|
||||
const extendFollow = (): void => {
|
||||
if (userDetachedRef.current) {
|
||||
return;
|
||||
|
|
@ -212,6 +237,13 @@ export function useIntentAwareAutoScroll(): {
|
|||
const detach = (): void => {
|
||||
userDetachedRef.current = true;
|
||||
followUntilRef.current = 0;
|
||||
// The stabilizer is only meaningful while we're actively
|
||||
// pinning to the bottom. Once the user scrolls up, drop any
|
||||
// residual padding so the bottom stays flush whenever they
|
||||
// come back. Safe here because the user is mid-content —
|
||||
// shrinking scrollHeight cannot cap their scrollTop.
|
||||
releaseStabilizer();
|
||||
maxContentHeight = el.scrollHeight;
|
||||
};
|
||||
|
||||
const requestTick = (): void => {
|
||||
|
|
@ -334,21 +366,116 @@ export function useIntentAwareAutoScroll(): {
|
|||
requestTick();
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
extendFollow();
|
||||
requestTick();
|
||||
});
|
||||
// Scroll stabilizer.
|
||||
//
|
||||
// Problem: when a trailing code block finalizes at stream end
|
||||
// (Streamdown flips `isAnimating` → false, shiki re-renders the
|
||||
// <pre> with highlight spans), the block's rendered height
|
||||
// briefly dips and then recovers a frame later. That dip shrinks
|
||||
// `scrollHeight`, which the browser handles by *synchronously*
|
||||
// capping `scrollTop` to the new (smaller) `scrollHeight −
|
||||
// clientHeight`. The cap is visible as a one-frame upward jump;
|
||||
// the recovery a frame or two later is the "snap back" the user
|
||||
// perceives as a flicker. No amount of programmatic re-scrolling
|
||||
// can prevent this — once `scrollHeight` drops, the cap has
|
||||
// already happened and `scrollTop` cannot be pushed past the new
|
||||
// max.
|
||||
//
|
||||
// Fix: keep `scrollHeight` monotonic across the follow window.
|
||||
// We track the maximum *content* height (scrollHeight minus our
|
||||
// own padding contribution) seen during follow, and compensate
|
||||
// for any shortfall by writing the deficit into a CSS custom
|
||||
// property `--aui-scroll-stabilizer`, which the viewport's
|
||||
// `padding-bottom` reads. A 5px content shrink instantly grows
|
||||
// the padding by 5px, so the browser sees no scrollHeight change
|
||||
// and never caps scrollTop. As content naturally grows past its
|
||||
// prior high-water mark (e.g. the next message streams in), the
|
||||
// padding shrinks back toward zero.
|
||||
//
|
||||
// Self-contained: lives entirely on the viewport element via a
|
||||
// CSS variable. Doesn't touch the composer, the action bar, the
|
||||
// message footer, the spacer, or any other UI.
|
||||
//
|
||||
// Returns the post-adjustment scrollHeight so a single layout
|
||||
// read per observer callback can feed both stabilization and
|
||||
// pinning, avoiding a redundant flush.
|
||||
const stabilize = (): number => {
|
||||
const sh = el.scrollHeight;
|
||||
const currentContent = sh - stabilizerPx;
|
||||
const followActive =
|
||||
!userDetachedRef.current &&
|
||||
performance.now() < followUntilRef.current;
|
||||
if (!followActive) {
|
||||
// Outside the follow window we stop adjusting, but we keep
|
||||
// `maxContentHeight` aligned with reality so the next follow
|
||||
// session starts from the current content size, not stale.
|
||||
maxContentHeight = currentContent;
|
||||
return sh;
|
||||
}
|
||||
if (currentContent > maxContentHeight) {
|
||||
maxContentHeight = currentContent;
|
||||
}
|
||||
const shrink = maxContentHeight - currentContent;
|
||||
// Large shrinks (over STABILIZER_MAX_PX) are intentional content
|
||||
// removals — message delete, regenerate clearing the old
|
||||
// assistant turn, reasoning-panel collapse. Compensating for
|
||||
// those would leave persistent empty space at the bottom of the
|
||||
// viewport, which the user reads as "weird empty gap." Release
|
||||
// the stabilizer instead and rebase the high-water mark; the
|
||||
// pinIfFollowing call right after will smoothly re-anchor to
|
||||
// the new (smaller) bottom.
|
||||
if (shrink > STABILIZER_MAX_PX) {
|
||||
maxContentHeight = currentContent;
|
||||
if (stabilizerPx !== 0) {
|
||||
stabilizerPx = 0;
|
||||
el.style.removeProperty("--aui-scroll-stabilizer");
|
||||
}
|
||||
return currentContent;
|
||||
}
|
||||
const needed = Math.max(0, shrink);
|
||||
if (needed !== stabilizerPx) {
|
||||
stabilizerPx = needed;
|
||||
el.style.setProperty(
|
||||
"--aui-scroll-stabilizer",
|
||||
`${stabilizerPx}px`,
|
||||
);
|
||||
}
|
||||
return currentContent + stabilizerPx;
|
||||
};
|
||||
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
extendFollow();
|
||||
requestTick();
|
||||
});
|
||||
// Synchronous pin-to-bottom. Observer callbacks run in the event-
|
||||
// loop's "update the rendering" step (after layout, before paint),
|
||||
// so the scrollTo here is composited in the same frame as the
|
||||
// mutation that triggered the observer.
|
||||
const pinIfFollowing = (scrollHeight: number): void => {
|
||||
if (userDetachedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (performance.now() >= followUntilRef.current) {
|
||||
return;
|
||||
}
|
||||
if (scrollHeight <= el.clientHeight) {
|
||||
return;
|
||||
}
|
||||
el.scrollTo({ top: scrollHeight, behavior: "instant" });
|
||||
};
|
||||
|
||||
const onViewportResize = () => {
|
||||
// All three layout-change signals fan in here so there's a
|
||||
// single place to understand "what runs when the viewport's
|
||||
// content shape changes". Order matters: extend first so the
|
||||
// stabilizer sees the follow window as active; stabilize before
|
||||
// pinning so we scroll to the post-adjustment scrollHeight.
|
||||
const onLayoutChange = (): void => {
|
||||
extendFollow();
|
||||
const scrollHeight = stabilize();
|
||||
pinIfFollowing(scrollHeight);
|
||||
requestTick();
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(onLayoutChange);
|
||||
const mutationObserver = new MutationObserver(onLayoutChange);
|
||||
const onViewportResize = onLayoutChange;
|
||||
|
||||
// Fresh attach always starts pinned. `userDetachedRef` survives
|
||||
// ref rebinds (it's hook-scoped), so if the viewport element is
|
||||
// ever unmounted and remounted without an AUI lifecycle event
|
||||
|
|
@ -366,7 +493,13 @@ export function useIntentAwareAutoScroll(): {
|
|||
setIsAtBottom(true);
|
||||
requestTick();
|
||||
|
||||
resizeObserver.observe(el);
|
||||
// Observe the border box, not the content box. The stabilizer
|
||||
// writes `padding-bottom`, which shrinks the content box; if we
|
||||
// observed that, every stabilizer adjustment would echo back as
|
||||
// a resize and re-enter onLayoutChange. Border-box stays put
|
||||
// through padding changes but still tracks parent-driven
|
||||
// resizes (window, sidebar toggle) — which is all we need.
|
||||
resizeObserver.observe(el, { box: "border-box" });
|
||||
mutationObserver.observe(el, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
|
|
|
|||
|
|
@ -1,69 +1,69 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none cursor-pointer",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
dark: "bg-foreground text-background hover:bg-foreground/85 dark:bg-foreground dark:text-background",
|
||||
outline:
|
||||
"border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive:
|
||||
"bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
||||
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}): React.ReactElement {
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none cursor-pointer",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
dark: "bg-foreground text-background hover:bg-foreground/85 dark:bg-foreground dark:text-background",
|
||||
outline:
|
||||
"border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive:
|
||||
"bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
||||
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}): React.ReactElement {
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,244 +1,257 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import { Select as SelectPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDialogPortalContainer } from "@/components/ui/dialog";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowUp01Icon,
|
||||
Tick02Icon,
|
||||
UnfoldMoreIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
||||
const SelectOpenContext = createContext(false);
|
||||
|
||||
function Select({
|
||||
onOpenChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<SelectOpenContext.Provider value={isOpen}>
|
||||
<SelectPrimitive.Root
|
||||
data-slot="select"
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
onOpenChange?.(open);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</SelectOpenContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
const isOpen = useContext(SelectOpenContext);
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
style={{
|
||||
borderRadius: isOpen ? "12px" : undefined,
|
||||
transition: isOpen
|
||||
? "border-radius 0ms"
|
||||
: "border-radius 150ms cubic-bezier(0.645, 0.045, 0.355, 1)",
|
||||
}}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-4xl border px-3 py-2 text-sm transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<HugeiconsIcon
|
||||
icon={UnfoldMoreIcon}
|
||||
strokeWidth={2}
|
||||
className="text-muted-foreground size-4 pointer-events-none"
|
||||
/>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
container,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
|
||||
container?: HTMLElement | null;
|
||||
}) {
|
||||
const dialogContainer = useDialogPortalContainer();
|
||||
return (
|
||||
<SelectPrimitive.Portal container={container ?? dialogContainer ?? undefined}>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 shadow-border ring-1 ring-border min-w-36 rounded-xl p-1 corner-squircle duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto ",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-[var(--radix-select-trigger-height)] data-[position=popper]:w-full data-[position=popper]:min-w-[var(--radix-select-trigger-width)]",
|
||||
position === "popper" && "",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-3 py-2.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-xl corner-squircle py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-pointer items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<HugeiconsIcon
|
||||
icon={Tick02Icon}
|
||||
strokeWidth={2}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn(
|
||||
"bg-border/50 -mx-1 my-1 h-px pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2} />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
"use client";
|
||||
|
||||
import { Select as SelectPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDialogPortalContainer } from "@/components/ui/dialog";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowUp01Icon,
|
||||
Tick02Icon,
|
||||
UnfoldMoreIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
||||
const SelectOpenContext = createContext(false);
|
||||
|
||||
function Select({
|
||||
onOpenChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<SelectOpenContext.Provider value={isOpen}>
|
||||
<SelectPrimitive.Root
|
||||
data-slot="select"
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
onOpenChange?.(open);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</SelectOpenContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
icon,
|
||||
iconClassName,
|
||||
animateRadius = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
icon?: typeof UnfoldMoreIcon;
|
||||
iconClassName?: string;
|
||||
animateRadius?: boolean;
|
||||
}) {
|
||||
const isOpen = useContext(SelectOpenContext);
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
style={
|
||||
animateRadius
|
||||
? {
|
||||
borderRadius: isOpen ? "12px" : undefined,
|
||||
transition: isOpen
|
||||
? "border-radius 0ms"
|
||||
: "border-radius 150ms cubic-bezier(0.645, 0.045, 0.355, 1)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-4xl border px-3 py-2 text-sm transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<HugeiconsIcon
|
||||
icon={icon ?? UnfoldMoreIcon}
|
||||
strokeWidth={2}
|
||||
className={cn(
|
||||
"text-muted-foreground size-4 pointer-events-none",
|
||||
iconClassName,
|
||||
)}
|
||||
/>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
container,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
|
||||
container?: HTMLElement | null;
|
||||
}) {
|
||||
const dialogContainer = useDialogPortalContainer();
|
||||
return (
|
||||
<SelectPrimitive.Portal container={container ?? dialogContainer ?? undefined}>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 shadow-border ring-1 ring-border min-w-36 rounded-xl p-1 corner-squircle duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto ",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-[var(--radix-select-trigger-height)] data-[position=popper]:w-full data-[position=popper]:min-w-[var(--radix-select-trigger-width)]",
|
||||
position === "popper" && "",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-3 py-2.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-xl corner-squircle py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-pointer items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<HugeiconsIcon
|
||||
icon={Tick02Icon}
|
||||
strokeWidth={2}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn(
|
||||
"bg-border/50 -mx-1 my-1 h-px pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2} />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,8 +10,12 @@ import { cn } from "@/lib/utils";
|
|||
type ToggleFn = () => void;
|
||||
const TooltipToggleCtx = createContext<ToggleFn | null>(null);
|
||||
|
||||
// Default to instant open (no hover delay). Most tooltips in the app —
|
||||
// chat-area icon labels, sidebar nav labels, the context/token
|
||||
// calculators — should feel snappy. Consumers that want a delay still
|
||||
// pass an explicit `delayDuration` prop.
|
||||
function TooltipProvider({
|
||||
delayDuration = 400,
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
|
|
@ -81,25 +85,35 @@ function TooltipTrigger({
|
|||
);
|
||||
}
|
||||
|
||||
type TooltipVariant = "default" | "rich" | "none";
|
||||
|
||||
// `default` applies the compact black-pill styling shared with the
|
||||
// sidebar/chat icon labels. `rich` opts into the larger multi-row
|
||||
// popover surface used for timing/context breakdowns. `none` is an
|
||||
// escape hatch for tooltips that need to bring their own surface.
|
||||
function TooltipContent({
|
||||
variant = "default",
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
|
||||
variant?: TooltipVariant;
|
||||
}) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-2xl corner-squircle px-3 py-1.5 text-xs **:data-[slot=kbd]:rounded-4xl bg-foreground text-background border border-foreground/40 shadow-lg z-[999999] w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin)",
|
||||
"z-[999999] w-fit max-w-xs",
|
||||
variant === "default" && "tooltip-compact",
|
||||
variant === "rich" && "tooltip-rich",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] data-[side=left]:translate-x-[-1.5px] data-[side=right]:translate-x-[1.5px] bg-foreground fill-foreground z-[999999] translate-y-[calc(-50%_-_2px)]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from "./chat-api";
|
||||
import { db } from "../db";
|
||||
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
|
||||
import { isMultimodalResponse } from "../types/api";
|
||||
import type { ChatModelSummary } from "../types/runtime";
|
||||
import {
|
||||
hasClosedThinkTag,
|
||||
|
|
@ -396,6 +397,8 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
loadedKvCacheDtype: loadResp.cache_type_kv ?? null,
|
||||
defaultChatTemplate: loadResp.chat_template ?? null,
|
||||
chatTemplateOverride: null,
|
||||
loadedChatTemplateOverride: null,
|
||||
loadedIsMultimodal: isMultimodalResponse(loadResp),
|
||||
});
|
||||
toast.success(`Loaded ${repo.repo_id} (${variant.quant})`, { id: toastId });
|
||||
return { loaded: true, blockedByTrustRemoteCode: false };
|
||||
|
|
@ -455,6 +458,9 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
if (!store.models.some((m) => m.id === repo.repo_id)) {
|
||||
store.setModels([...store.models, sfModel]);
|
||||
}
|
||||
useChatRuntimeStore.setState({
|
||||
loadedIsMultimodal: isMultimodalResponse(sfLoadResp),
|
||||
});
|
||||
toast.success(`Loaded ${repo.repo_id}`, { id: toastId });
|
||||
return { loaded: true, blockedByTrustRemoteCode: false };
|
||||
} catch {
|
||||
|
|
@ -522,6 +528,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
loadedKvCacheDtype: loadResp.cache_type_kv ?? null,
|
||||
defaultChatTemplate: loadResp.chat_template ?? null,
|
||||
chatTemplateOverride: null,
|
||||
loadedIsMultimodal: isMultimodalResponse(loadResp),
|
||||
});
|
||||
toast.success("Loaded Gemma-4-E2B-it (UD-Q4_K_XL)", { id: toastId });
|
||||
return { loaded: true, blockedByTrustRemoteCode: false };
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { isTauri } from "@/lib/api-base";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Settings05Icon } from "@hugeicons/core-free-icons";
|
||||
import { CustomizeIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
|
|
@ -272,10 +272,10 @@ function CompareShell({
|
|||
>
|
||||
{children}
|
||||
</div>
|
||||
<div className="shrink-0 bg-background px-5 pb-2 pt-1">
|
||||
<div className="mx-auto w-full max-w-[44rem]">{composer}</div>
|
||||
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">
|
||||
LLMs can make mistakes. Double-check all responses.
|
||||
<div className="shrink-0 bg-background pl-5 pr-5 md:pr-[30px] pb-2 pt-1">
|
||||
<div className="mx-auto w-full max-w-[48rem]">{composer}</div>
|
||||
<p className="composer-footer-note">
|
||||
LLMs can make mistakes. Double-check responses.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1073,14 +1073,22 @@ export function ChatPage(): ReactElement {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="flex h-[34px] w-[34px] items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="flex h-[34px] w-[34px] items-center justify-center rounded-[12px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Open configuration"
|
||||
data-tour="chat-settings"
|
||||
>
|
||||
<HugeiconsIcon icon={Settings05Icon} className="size-5" />
|
||||
<HugeiconsIcon
|
||||
icon={CustomizeIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon"
|
||||
/>
|
||||
</button>
|
||||
</TooltipPrimitive.Trigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
className="tooltip-compact"
|
||||
>
|
||||
Open configuration
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -46,14 +46,14 @@ export const ContextUsageBar: FC<{
|
|||
type="button"
|
||||
aria-label={`Context usage: ${formatTokenCount(used)} of ${formatTokenCount(total)} tokens`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1 text-xs font-mono tabular-nums text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
"flex items-center gap-2 rounded-[10px] px-2.5 py-1 font-mono text-chat-icon-fg text-[13px] tabular-nums transition-colors hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{formatTokenCount(used)} / {formatTokenCount(total)}
|
||||
</span>
|
||||
<div className="h-1.5 w-16 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-1.5 w-16 rounded-full bg-black/10 dark:bg-white/15 overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", severity.bar)}
|
||||
style={{ width: `${percent}%` }}
|
||||
|
|
@ -64,7 +64,8 @@ export const ContextUsageBar: FC<{
|
|||
<TooltipContent
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
className="[&_span>svg]:hidden! rounded-lg border bg-popover px-3 py-2 text-popover-foreground shadow-md"
|
||||
variant="rich"
|
||||
className="[&_span>svg]:hidden!"
|
||||
>
|
||||
<div className="grid min-w-44 gap-1.5 text-xs">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
|
|
|
|||
|
|
@ -28,24 +28,14 @@ import {
|
|||
mergeBackendRecommendedInference,
|
||||
resolveLoadMaxSeqLength,
|
||||
} from "../presets/preset-policy";
|
||||
import {
|
||||
isMultimodalResponse,
|
||||
} from "../types/api";
|
||||
import type {
|
||||
ChatLoraSummary,
|
||||
ChatModelSummary,
|
||||
} from "../types/runtime";
|
||||
|
||||
// The simplified Speculative Decoding control surfaces "default" (which
|
||||
// maps to llama.cpp's --spec-default) and "off". A backend status / load
|
||||
// response can still report the older manual modes (ngram-mod,
|
||||
// ngram-simple) when a model is loaded via the API or carried over from an
|
||||
// older Studio version. The Select would render an empty trigger for those
|
||||
// values, so coerce them to "default" -- llama.cpp's own --spec-default
|
||||
// picks an equivalent strategy and keeps the dropdown coherent.
|
||||
function normalizeSpeculativeType(v: string | null | undefined): string | null {
|
||||
if (v == null) return null;
|
||||
if (v === "default" || v === "off") return v;
|
||||
return "default";
|
||||
}
|
||||
|
||||
type SelectedModelInput = {
|
||||
id: string;
|
||||
isLora?: boolean;
|
||||
|
|
@ -147,6 +137,12 @@ function getTrustRemoteCodeRequiredMessage(modelName: string): string {
|
|||
return `${modelName} needs custom code enabled to load. Turn on "Enable custom code" in Chat Settings, then try again.`;
|
||||
}
|
||||
|
||||
function normalizeSpeculativeType(v: string | null | undefined): string | null {
|
||||
if (v == null) return null;
|
||||
if (v === "default" || v === "off") return v;
|
||||
return "default";
|
||||
}
|
||||
|
||||
export function useChatModelRuntime() {
|
||||
const params = useChatRuntimeStore((state) => state.params);
|
||||
const models = useChatRuntimeStore((state) => state.models);
|
||||
|
|
@ -256,10 +252,19 @@ export function useChatModelRuntime() {
|
|||
const ggufNativeContextLength = statusRes.is_gguf
|
||||
? (statusRes.native_context_length ?? null)
|
||||
: null;
|
||||
const currentSpecType = normalizeSpeculativeType(statusRes.speculative_type);
|
||||
const currentSpecType = normalizeSpeculativeType(
|
||||
statusRes.speculative_type,
|
||||
);
|
||||
// Refresh runs both on F5 (fresh store needs hydration) AND right
|
||||
// after a fresh load (store was already set by the load path). For
|
||||
// the user-configurable model params we only hydrate when the shadow
|
||||
// `loaded*` field is still null -- that signals "not yet hydrated".
|
||||
// Otherwise we'd clobber the values the load path just applied and
|
||||
// the UI would appear to revert the user's changes.
|
||||
const prevState = useChatRuntimeStore.getState();
|
||||
const nextDefaultChatTemplate =
|
||||
statusRes.chat_template === undefined
|
||||
? useChatRuntimeStore.getState().defaultChatTemplate
|
||||
? prevState.defaultChatTemplate
|
||||
: statusRes.chat_template;
|
||||
useChatRuntimeStore.setState({
|
||||
supportsReasoning,
|
||||
|
|
@ -278,8 +283,22 @@ export function useChatModelRuntime() {
|
|||
modelRequiresTrustRemoteCode:
|
||||
statusRes.requires_trust_remote_code ?? false,
|
||||
defaultChatTemplate: nextDefaultChatTemplate,
|
||||
speculativeType: currentSpecType,
|
||||
loadedSpeculativeType: currentSpecType,
|
||||
loadedIsMultimodal: isMultimodalResponse(statusRes),
|
||||
...(prevState.loadedSpeculativeType === null && {
|
||||
speculativeType: currentSpecType,
|
||||
loadedSpeculativeType: currentSpecType,
|
||||
}),
|
||||
...(statusRes.cache_type_kv !== undefined &&
|
||||
prevState.loadedKvCacheDtype === null && {
|
||||
kvCacheDtype: statusRes.cache_type_kv,
|
||||
loadedKvCacheDtype: statusRes.cache_type_kv,
|
||||
}),
|
||||
...(statusRes.chat_template_override !== undefined &&
|
||||
prevState.loadedChatTemplateOverride === null &&
|
||||
prevState.chatTemplateOverride === null && {
|
||||
chatTemplateOverride: statusRes.chat_template_override,
|
||||
loadedChatTemplateOverride: statusRes.chat_template_override,
|
||||
}),
|
||||
});
|
||||
|
||||
// Set reasoning default for Qwen3.5/3.6 small models
|
||||
|
|
@ -297,6 +316,7 @@ export function useChatModelRuntime() {
|
|||
} else {
|
||||
useChatRuntimeStore.setState({
|
||||
modelRequiresTrustRemoteCode: false,
|
||||
loadedIsMultimodal: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -492,6 +512,8 @@ export function useChatModelRuntime() {
|
|||
maxSeqLength,
|
||||
presetSource: activePresetSource,
|
||||
});
|
||||
const effectiveChatTemplateOverride =
|
||||
chatTemplateOverride?.trim() ? chatTemplateOverride : null;
|
||||
const loadResponse = await loadModel({
|
||||
model_path: modelId,
|
||||
nativePathLease: loadNativePathLease,
|
||||
|
|
@ -501,7 +523,7 @@ export function useChatModelRuntime() {
|
|||
is_lora: isLora,
|
||||
gguf_variant: ggufVariant ?? null,
|
||||
trust_remote_code: trustRemoteCode,
|
||||
chat_template_override: chatTemplateOverride,
|
||||
chat_template_override: effectiveChatTemplateOverride,
|
||||
cache_type_kv: kvCacheDtype,
|
||||
speculative_type: speculativeType,
|
||||
});
|
||||
|
|
@ -531,7 +553,9 @@ export function useChatModelRuntime() {
|
|||
}
|
||||
}
|
||||
const loadedKv = loadResponse.cache_type_kv ?? null;
|
||||
const loadedSpec = normalizeSpeculativeType(loadResponse.speculative_type);
|
||||
const loadedSpec = normalizeSpeculativeType(
|
||||
loadResponse.speculative_type,
|
||||
);
|
||||
const nativeCtx = loadResponse.is_gguf
|
||||
? (loadResponse.context_length ?? 131072)
|
||||
: null;
|
||||
|
|
@ -566,11 +590,16 @@ export function useChatModelRuntime() {
|
|||
loadedSpeculativeType: loadedSpec,
|
||||
customContextLength: keepCustomCtx,
|
||||
defaultChatTemplate: loadResponse.chat_template ?? null,
|
||||
chatTemplateOverride: null,
|
||||
chatTemplateOverride: effectiveChatTemplateOverride,
|
||||
loadedChatTemplateOverride: effectiveChatTemplateOverride,
|
||||
loadedIsMultimodal: isMultimodalResponse(loadResponse),
|
||||
activeNativePathToken: nativePathToken ?? null,
|
||||
});
|
||||
// Qwen3/3.5/3.6: apply thinking-mode-specific params after load
|
||||
if (modelId.toLowerCase().includes("qwen3") && (loadResponse.supports_reasoning ?? false)) {
|
||||
if (
|
||||
modelId.toLowerCase().includes("qwen3") &&
|
||||
(loadResponse.supports_reasoning ?? false)
|
||||
) {
|
||||
const store = useChatRuntimeStore.getState();
|
||||
if (store.activePresetSource === "builtin-default") {
|
||||
const mid = modelId.toLowerCase();
|
||||
|
|
|
|||
|
|
@ -66,6 +66,29 @@ function cancelIfRunning(threadId: string): void {
|
|||
cancelByThreadId[threadId]?.();
|
||||
}
|
||||
|
||||
export async function renameChatItem(
|
||||
item: SidebarItem,
|
||||
nextTitle: string,
|
||||
): Promise<void> {
|
||||
const trimmed = nextTitle.trim();
|
||||
if (!trimmed || trimmed === item.title) return;
|
||||
|
||||
if (item.type === "single") {
|
||||
await db.threads.update(item.id, { title: trimmed });
|
||||
return;
|
||||
}
|
||||
|
||||
const pairThreads = await db.threads
|
||||
.where("pairId")
|
||||
.equals(item.id)
|
||||
.toArray();
|
||||
await db.transaction("rw", db.threads, async () => {
|
||||
for (const t of pairThreads) {
|
||||
await db.threads.update(t.id, { title: trimmed });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteChatItem(
|
||||
item: SidebarItem,
|
||||
activeId: string | undefined,
|
||||
|
|
|
|||
|
|
@ -9,5 +9,13 @@ export {
|
|||
type Preset,
|
||||
} from "./chat-settings-sheet";
|
||||
export { useChatRuntimeStore } from "./stores/chat-runtime-store";
|
||||
export { useChatSearchStore } from "./stores/chat-search-store";
|
||||
export { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
|
||||
export { ChatSearchDialog } from "./components/chat-search-dialog";
|
||||
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";
|
||||
export {
|
||||
deleteChatItem,
|
||||
renameChatItem,
|
||||
useChatSidebarItems,
|
||||
type SidebarItem,
|
||||
} from "./hooks/use-chat-sidebar-items";
|
||||
|
|
|
|||
|
|
@ -27,44 +27,16 @@ export type PresetOwnedParams = Pick<
|
|||
|
||||
export const BUILTIN_PRESETS: Preset[] = [
|
||||
{ name: "Default", params: { ...defaultInferenceParams } },
|
||||
{
|
||||
name: "Creative",
|
||||
params: {
|
||||
...defaultInferenceParams,
|
||||
temperature: 1.5,
|
||||
topP: 1.0,
|
||||
topK: 0,
|
||||
minP: 0.1,
|
||||
repetitionPenalty: 1.0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Precise",
|
||||
params: {
|
||||
...defaultInferenceParams,
|
||||
temperature: 0.1,
|
||||
topP: 0.95,
|
||||
topK: 80,
|
||||
minP: 0.01,
|
||||
repetitionPenalty: 1.0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const BUILTIN_PRESET_NAMES = new Set(
|
||||
BUILTIN_PRESETS.map((preset) => preset.name),
|
||||
);
|
||||
|
||||
export type ChatPresetSource =
|
||||
| "builtin-default"
|
||||
| "builtin-fixed"
|
||||
| "custom"
|
||||
| "modified";
|
||||
export type ChatPresetSource = "builtin-default" | "custom" | "modified";
|
||||
|
||||
export function getPresetSource(name: string): ChatPresetSource {
|
||||
if (name === "Default") return "builtin-default";
|
||||
if (BUILTIN_PRESET_NAMES.has(name)) return "builtin-fixed";
|
||||
return "custom";
|
||||
return name === "Default" ? "builtin-default" : "custom";
|
||||
}
|
||||
|
||||
export function getUniquePresetName(
|
||||
|
|
@ -102,7 +74,9 @@ export function normalizeCustomPresets(presets: Preset[]): Preset[] {
|
|||
return presets
|
||||
.map((preset): Preset | null => {
|
||||
const trimmedName = preset.name.trim();
|
||||
if (!trimmedName) return null;
|
||||
if (!trimmedName) {
|
||||
return null;
|
||||
}
|
||||
const name = usedNames.has(trimmedName)
|
||||
? getBuiltinVariantName(trimmedName, usedNames)
|
||||
: trimmedName;
|
||||
|
|
@ -119,6 +93,21 @@ export function getOrderedPresets(customPresets: Preset[]): Preset[] {
|
|||
return [...BUILTIN_PRESETS, ...normalizeCustomPresets(customPresets)];
|
||||
}
|
||||
|
||||
export function getPresetOwnedParams(
|
||||
params: InferenceParams,
|
||||
): PresetOwnedParams {
|
||||
return {
|
||||
temperature: params.temperature,
|
||||
topP: params.topP,
|
||||
topK: params.topK,
|
||||
minP: params.minP,
|
||||
repetitionPenalty: params.repetitionPenalty,
|
||||
presencePenalty: params.presencePenalty,
|
||||
maxTokens: params.maxTokens,
|
||||
systemPrompt: params.systemPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export function isSamePresetConfig(
|
||||
a: InferenceParams,
|
||||
b: InferenceParams,
|
||||
|
|
@ -137,21 +126,6 @@ export function isSamePresetConfig(
|
|||
);
|
||||
}
|
||||
|
||||
export function getPresetOwnedParams(
|
||||
params: InferenceParams,
|
||||
): PresetOwnedParams {
|
||||
return {
|
||||
temperature: params.temperature,
|
||||
topP: params.topP,
|
||||
topK: params.topK,
|
||||
minP: params.minP,
|
||||
repetitionPenalty: params.repetitionPenalty,
|
||||
presencePenalty: params.presencePenalty,
|
||||
maxTokens: params.maxTokens,
|
||||
systemPrompt: params.systemPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPresetOwnedConfigKey(params: InferenceParams): string {
|
||||
return JSON.stringify(getPresetOwnedParams(params));
|
||||
}
|
||||
|
|
@ -211,7 +185,10 @@ export function getPresetSaveState({
|
|||
}
|
||||
|
||||
if (BUILTIN_PRESET_NAMES.has(trimmedName)) {
|
||||
const variantName = getBuiltinVariantName(trimmedName, new Set(presets.map((preset) => preset.name)));
|
||||
const variantName = getBuiltinVariantName(
|
||||
trimmedName,
|
||||
new Set(presets.map((preset) => preset.name)),
|
||||
);
|
||||
return {
|
||||
mode: "copy-builtin",
|
||||
canSubmit: activePreset !== trimmedName || hasUnsavedPresetChanges,
|
||||
|
|
@ -234,8 +211,7 @@ export function getPresetSaveState({
|
|||
mode: isActiveMatch ? "overwrite-active" : "overwrite-other",
|
||||
canSubmit: !isActiveMatch || hasUnsavedPresetChanges,
|
||||
isSaveReady: !isActiveMatch || hasUnsavedPresetChanges,
|
||||
buttonLabel:
|
||||
isActiveMatch && !hasUnsavedPresetChanges ? "Saved" : "Save",
|
||||
buttonLabel: isActiveMatch && !hasUnsavedPresetChanges ? "Saved" : "Save",
|
||||
title: isActiveMatch
|
||||
? hasUnsavedPresetChanges
|
||||
? "Save current settings to this preset"
|
||||
|
|
@ -307,7 +283,8 @@ export function mergeBackendRecommendedInference({
|
|||
...next,
|
||||
maxTokens: defaultMaxTokens,
|
||||
temperature:
|
||||
toFiniteNumber(inference?.temperature) ?? defaultInferenceParams.temperature,
|
||||
toFiniteNumber(inference?.temperature) ??
|
||||
defaultInferenceParams.temperature,
|
||||
topP: toFiniteNumber(inference?.top_p) ?? defaultInferenceParams.topP,
|
||||
topK: toFiniteNumber(inference?.top_k) ?? defaultInferenceParams.topK,
|
||||
minP: toFiniteNumber(inference?.min_p) ?? defaultInferenceParams.minP,
|
||||
|
|
@ -343,9 +320,17 @@ export function resolveLoadMaxSeqLength({
|
|||
currentCheckpoint === modelId &&
|
||||
(ggufVariant ?? null) === (activeGgufVariant ?? null);
|
||||
|
||||
if (customContextLength != null) return customContextLength;
|
||||
if (isGgufLoad && presetSource === "builtin-default") return 0;
|
||||
if (isReloadingCurrentGguf) return ggufContextLength ?? 0;
|
||||
if (isGgufLoad) return 0;
|
||||
if (customContextLength != null) {
|
||||
return customContextLength;
|
||||
}
|
||||
if (isGgufLoad && presetSource === "builtin-default") {
|
||||
return 0;
|
||||
}
|
||||
if (isReloadingCurrentGguf) {
|
||||
return ggufContextLength ?? 0;
|
||||
}
|
||||
if (isGgufLoad) {
|
||||
return 0;
|
||||
}
|
||||
return maxSeqLength;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -14,12 +15,12 @@ import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params";
|
|||
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { useAui } from "@assistant-ui/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowUpIcon, GlobeIcon, HeadphonesIcon, LightbulbIcon, LightbulbOffIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { loadModel, validateModel } from "./api/chat-api";
|
||||
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
|
||||
import {
|
||||
type CompositionEvent,
|
||||
type KeyboardEvent,
|
||||
type MutableRefObject,
|
||||
type ReactElement,
|
||||
|
|
@ -52,6 +53,10 @@ export interface CompareHandle {
|
|||
const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif";
|
||||
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
|
||||
|
||||
function isNativeComposing(event: Event) {
|
||||
return "isComposing" in event && (event as InputEvent).isComposing === true;
|
||||
}
|
||||
|
||||
function fileToBase64DataURL(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
|
@ -238,7 +243,9 @@ export function SharedComposer({
|
|||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const [pendingAudio, setPendingAudio] = useState<{ name: string; base64: string } | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const composingRef = useRef(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const audioInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
|
|
@ -323,7 +330,13 @@ export function SharedComposer({
|
|||
setPendingImages((prev) => prev.filter((p) => p.id !== id));
|
||||
}, []);
|
||||
|
||||
function setCompositionState(next: boolean) {
|
||||
composingRef.current = next;
|
||||
setIsComposing(next);
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (composingRef.current) return;
|
||||
const msg = text.trim();
|
||||
if (!msg && pendingImages.length === 0 && !pendingAudio) return;
|
||||
|
||||
|
|
@ -358,6 +371,8 @@ export function SharedComposer({
|
|||
const maxSeqLength = store.params.maxSeqLength;
|
||||
const trustRemoteCode = store.params.trustRemoteCode ?? false;
|
||||
const chatTemplateOverride = store.chatTemplateOverride;
|
||||
const effectiveChatTemplateOverride =
|
||||
chatTemplateOverride?.trim() ? chatTemplateOverride : null;
|
||||
|
||||
function modelDisplayName(id: string): string {
|
||||
const parts = id.split("/");
|
||||
|
|
@ -379,7 +394,7 @@ export function SharedComposer({
|
|||
is_lora: sel.isLora,
|
||||
gguf_variant: sel.ggufVariant ?? null,
|
||||
trust_remote_code: trustRemoteCode,
|
||||
chat_template_override: chatTemplateOverride,
|
||||
chat_template_override: effectiveChatTemplateOverride,
|
||||
});
|
||||
if (validation.requires_trust_remote_code && !trustRemoteCode) {
|
||||
throw new Error(
|
||||
|
|
@ -395,7 +410,7 @@ export function SharedComposer({
|
|||
is_lora: sel.isLora,
|
||||
gguf_variant: sel.ggufVariant ?? null,
|
||||
trust_remote_code: trustRemoteCode,
|
||||
chat_template_override: chatTemplateOverride,
|
||||
chat_template_override: effectiveChatTemplateOverride,
|
||||
});
|
||||
const store = useChatRuntimeStore.getState();
|
||||
store.setCheckpoint(
|
||||
|
|
@ -480,6 +495,9 @@ export function SharedComposer({
|
|||
const busy = running || comparing;
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
// IME composition (Japanese/Chinese/Korean): Enter commits the candidate.
|
||||
// Don't hijack it. See issue #5318.
|
||||
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (!busy) {
|
||||
|
|
@ -488,11 +506,11 @@ export function SharedComposer({
|
|||
}
|
||||
}
|
||||
|
||||
const canSend = (text.trim().length > 0 || pendingImages.length > 0 || pendingAudio !== null) && !busy;
|
||||
const canSend = (text.trim().length > 0 || pendingImages.length > 0 || pendingAudio !== null) && !busy && !isComposing;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`chat-composer-surface relative flex w-full flex-col rounded-3xl bg-background dark:bg-card px-1 pt-2 transition-shadow outline-none ${dragging ? "border-ring bg-accent/50" : ""}`}
|
||||
className={`chat-composer-surface ${dragging ? "border-ring bg-accent/50" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
if (isTauri) return;
|
||||
e.preventDefault();
|
||||
|
|
@ -536,13 +554,29 @@ export function SharedComposer({
|
|||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onChange={(e) => {
|
||||
// ALWAYS mirror the DOM value into React state, even during IME
|
||||
// composition. The controlled `value` prop must match the DOM at
|
||||
// all times, otherwise any unrelated parent re-render reconciles
|
||||
// the textarea back to the stored value mid-composition — wiping
|
||||
// the IME preedit AND prior committed text (e.g. Tab cycling
|
||||
// candidates erases earlier words). Issue #5318.
|
||||
setCompositionState(isNativeComposing(e.nativeEvent));
|
||||
setText(e.target.value);
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
setCompositionState(true);
|
||||
}}
|
||||
onCompositionEnd={(e: CompositionEvent<HTMLTextAreaElement>) => {
|
||||
setCompositionState(false);
|
||||
setText(e.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Send to both models..."
|
||||
className="mb-1 min-h-12 w-full resize-none overflow-y-hidden bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0"
|
||||
className="composer-input"
|
||||
rows={1}
|
||||
/>
|
||||
<div className="relative mx-2 mb-2 flex items-center justify-between">
|
||||
<div className="composer-action-wrapper">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
|
@ -682,14 +716,8 @@ export function SharedComposer({
|
|||
type="button"
|
||||
disabled={toolsDisabled}
|
||||
onClick={() => setToolsEnabled(!toolsEnabled)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
toolsDisabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: toolsEnabled
|
||||
? "bg-primary/10 text-primary hover:bg-primary/20"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
|
||||
)}
|
||||
className="composer-pill-btn"
|
||||
data-active={toolsEnabled && !toolsDisabled ? "true" : "false"}
|
||||
aria-label={toolsEnabled ? "Disable web search" : "Enable web search"}
|
||||
>
|
||||
<GlobeIcon className="size-3.5" />
|
||||
|
|
@ -699,14 +727,8 @@ export function SharedComposer({
|
|||
type="button"
|
||||
disabled={toolsDisabled}
|
||||
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
toolsDisabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: codeToolsEnabled
|
||||
? "bg-primary/10 text-primary hover:bg-primary/20"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
|
||||
)}
|
||||
className="composer-pill-btn"
|
||||
data-active={codeToolsEnabled && !toolsDisabled ? "true" : "false"}
|
||||
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
|
||||
>
|
||||
<CodeToggleIcon className="size-3.5" />
|
||||
|
|
@ -762,6 +784,7 @@ export function SharedComposer({
|
|||
className="size-8 rounded-full"
|
||||
onClick={send}
|
||||
disabled={!canSend}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<ArrowUpIcon className="size-4" />
|
||||
</TooltipIconButton>
|
||||
|
|
|
|||
|
|
@ -212,9 +212,11 @@ type ChatRuntimeStore = {
|
|||
loadedKvCacheDtype: string | null;
|
||||
speculativeType: string | null;
|
||||
loadedSpeculativeType: string | null;
|
||||
loadedIsMultimodal: boolean;
|
||||
customContextLength: number | null;
|
||||
defaultChatTemplate: string | null;
|
||||
chatTemplateOverride: string | null;
|
||||
loadedChatTemplateOverride: string | null;
|
||||
activeThreadId: string | null;
|
||||
settingsPanelOpen: boolean;
|
||||
pendingAudioBase64: string | null;
|
||||
|
|
@ -297,9 +299,11 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
|
|||
loadedKvCacheDtype: null,
|
||||
speculativeType: "default",
|
||||
loadedSpeculativeType: null,
|
||||
loadedIsMultimodal: false,
|
||||
customContextLength: null,
|
||||
defaultChatTemplate: null,
|
||||
chatTemplateOverride: null,
|
||||
loadedChatTemplateOverride: null,
|
||||
activeThreadId: null,
|
||||
settingsPanelOpen: false,
|
||||
pendingAudioBase64: null,
|
||||
|
|
@ -399,9 +403,11 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
|
|||
loadedKvCacheDtype: null,
|
||||
speculativeType: "default",
|
||||
loadedSpeculativeType: null,
|
||||
loadedIsMultimodal: false,
|
||||
customContextLength: null,
|
||||
defaultChatTemplate: null,
|
||||
chatTemplateOverride: null,
|
||||
loadedChatTemplateOverride: null,
|
||||
})),
|
||||
setReasoningEnabled: (reasoningEnabled) => set({ reasoningEnabled }),
|
||||
setReasoningStyle: (reasoningStyle) => set({ reasoningStyle }),
|
||||
|
|
|
|||
|
|
@ -70,6 +70,25 @@ export interface GgufVariantsResponse {
|
|||
default_variant: string | null;
|
||||
}
|
||||
|
||||
export function isMultimodalResponse(
|
||||
response:
|
||||
| {
|
||||
is_vision?: boolean;
|
||||
is_audio?: boolean;
|
||||
audio_type?: string | null;
|
||||
has_audio_input?: boolean;
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
): boolean {
|
||||
return (
|
||||
Boolean(response?.is_vision) ||
|
||||
Boolean(response?.is_audio) ||
|
||||
Boolean(response?.has_audio_input) ||
|
||||
response?.audio_type === "audio_vlm"
|
||||
);
|
||||
}
|
||||
|
||||
export interface LoadModelResponse {
|
||||
status: string;
|
||||
model: string;
|
||||
|
|
@ -134,6 +153,8 @@ export interface InferenceStatusResponse {
|
|||
context_length?: number | null;
|
||||
max_context_length?: number | null;
|
||||
native_context_length?: number | null;
|
||||
cache_type_kv?: string | null;
|
||||
chat_template_override?: string | null;
|
||||
speculative_type?: string | null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -958,7 +958,7 @@ export function ExportPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl bg-muted/50 p-3">
|
||||
<div className="rounded-xl bg-foreground/[0.04] p-3">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Direct model exports currently support GGUF only.
|
||||
</p>
|
||||
|
|
@ -968,7 +968,7 @@ export function ExportPage() {
|
|||
</AnimatePresence>
|
||||
|
||||
{sourceMode === "checkpoint" && (
|
||||
<div className="rounded-xl bg-muted/50 p-3 flex flex-col gap-2">
|
||||
<div className="rounded-xl bg-foreground/[0.04] p-3 flex flex-col gap-2">
|
||||
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Training Info
|
||||
</span>
|
||||
|
|
@ -1010,7 +1010,7 @@ export function ExportPage() {
|
|||
key={step}
|
||||
className="flex items-start gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-semibold">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-foreground/10 text-[10px] font-semibold">
|
||||
{i + 1}
|
||||
</span>
|
||||
{step}
|
||||
|
|
|
|||
|
|
@ -114,17 +114,17 @@ export function SettingsDialog() {
|
|||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"relative flex h-[30px] items-center gap-2.5 rounded-[8px] px-2.5 text-sm font-medium transition-colors",
|
||||
"relative flex h-[32px] items-center gap-2.5 rounded-[8px] px-2.5 text-[14.5px] leading-[19px] tracking-nav font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
||||
active
|
||||
? "text-black dark:text-white"
|
||||
: "text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white",
|
||||
: "text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec] dark:hover:bg-[#2d2f33] hover:text-black dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId="settings-active-pill"
|
||||
className="absolute inset-0 rounded-[8px] bg-[#ececec] dark:bg-[#2e3035]"
|
||||
className="absolute inset-0 rounded-[8px] bg-[#ececec] dark:bg-[#2d2f33]"
|
||||
transition={
|
||||
reduced
|
||||
? { duration: 0 }
|
||||
|
|
@ -139,8 +139,8 @@ export function SettingsDialog() {
|
|||
)}
|
||||
<HugeiconsIcon
|
||||
icon={tab.icon}
|
||||
strokeWidth={1.5}
|
||||
className="relative z-10 size-[18px]"
|
||||
strokeWidth={1.75}
|
||||
className="relative z-10 size-icon"
|
||||
/>
|
||||
<span className="relative z-10 min-w-0 truncate">{tab.label}</span>
|
||||
{tab.badge ? (
|
||||
|
|
@ -158,7 +158,7 @@ export function SettingsDialog() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={closeDialog}
|
||||
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2d2f33] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Close settings"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import type { TrainingViewData } from "@/features/training";
|
||||
import { getTrainingRun } from "@/features/training";
|
||||
import { getTrainingRun, onTrainingRunUpdated } from "@/features/training";
|
||||
import type { TrainingRunDetailResponse } from "@/features/training";
|
||||
import { parseBackendTrainingMethod } from "@/features/training/lib/training-methods";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
|
|
@ -70,7 +70,7 @@ function mapToViewData(detail: TrainingRunDetailResponse): TrainingViewData {
|
|||
: run.error_message ?? "Training errored",
|
||||
error: run.status === "error" ? run.error_message : null,
|
||||
isTrainingRunning: false,
|
||||
modelName: run.model_name,
|
||||
modelName: run.display_name ?? run.model_name,
|
||||
trainingMethod: parseBackendTrainingMethod(
|
||||
detail.config?.training_type,
|
||||
detail.config?.load_in_4bit,
|
||||
|
|
@ -109,6 +109,14 @@ export function HistoricalTrainingView({
|
|||
};
|
||||
}, [runId]);
|
||||
|
||||
useEffect(() => {
|
||||
const offUpdated = onTrainingRunUpdated((updated) => {
|
||||
if (updated.id !== runId) return;
|
||||
setDetail((prev) => (prev ? { ...prev, run: updated } : prev));
|
||||
});
|
||||
return offUpdated;
|
||||
}, [runId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-8 text-sm text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ import { Button } from "@/components/ui/button";
|
|||
import type { TrainingRunSummary } from "@/features/training";
|
||||
import {
|
||||
deleteTrainingRun,
|
||||
emitTrainingRunDeleted,
|
||||
listTrainingRuns,
|
||||
onTrainingRunDeleted,
|
||||
onTrainingRunsChanged,
|
||||
onTrainingRunUpdated,
|
||||
useTrainingActions,
|
||||
useTrainingRuntimeStore,
|
||||
} from "@/features/training";
|
||||
|
|
@ -180,6 +184,11 @@ export function HistoryCardGrid({
|
|||
const pollControllerRef = useRef<AbortController | null>(null);
|
||||
const fetchIdRef = useRef(0);
|
||||
const pollIdRef = useRef(0);
|
||||
const runsLengthRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
runsLengthRef.current = runs.length;
|
||||
}, [runs.length]);
|
||||
|
||||
const fetchRuns = useCallback(async (offset = 0, append = false, limit = PAGE_SIZE) => {
|
||||
// Cancel any in-flight poll so its stale response can't clobber this fresher fetch
|
||||
|
|
@ -216,6 +225,27 @@ export function HistoryCardGrid({
|
|||
};
|
||||
}, [fetchRuns]);
|
||||
|
||||
useEffect(() => {
|
||||
const offUpdated = onTrainingRunUpdated((updated) => {
|
||||
setRuns((prev) =>
|
||||
prev.map((run) => (run.id === updated.id ? updated : run)),
|
||||
);
|
||||
});
|
||||
const offDeleted = onTrainingRunDeleted((runId) => {
|
||||
setRuns((prev) => prev.filter((run) => run.id !== runId));
|
||||
setTotal((prev) => Math.max(0, prev - 1));
|
||||
});
|
||||
const offChanged = onTrainingRunsChanged(() => {
|
||||
const limit = Math.max(PAGE_SIZE, runsLengthRef.current);
|
||||
void fetchRuns(0, false, limit);
|
||||
});
|
||||
return () => {
|
||||
offUpdated();
|
||||
offDeleted();
|
||||
offChanged();
|
||||
};
|
||||
}, [fetchRuns]);
|
||||
|
||||
// Poll while any run is still "running" so the card shows live progress
|
||||
const hasRunningRun = runs.some((r) => r.status === "running");
|
||||
const visibleCount = runs.length;
|
||||
|
|
@ -248,9 +278,7 @@ export function HistoryCardGrid({
|
|||
setDeleteError(null);
|
||||
try {
|
||||
await deleteTrainingRun(deleteTarget);
|
||||
// Optimistically remove the card so it disappears immediately
|
||||
setRuns((prev) => prev.filter((r) => r.id !== deleteTarget));
|
||||
setTotal((prev) => Math.max(0, prev - 1));
|
||||
emitTrainingRunDeleted(deleteTarget);
|
||||
// Re-fetch preserving visible count so offsets stay consistent for "Load more"
|
||||
const currentCount = runs.length - 1;
|
||||
const limit = Math.max(PAGE_SIZE, currentCount);
|
||||
|
|
@ -366,11 +394,22 @@ export function HistoryCardGrid({
|
|||
<div className="min-w-0">
|
||||
<p
|
||||
className="truncate text-sm font-medium"
|
||||
title={run.model_name}
|
||||
title={run.display_name ?? run.model_name}
|
||||
>
|
||||
{run.model_name}
|
||||
{run.display_name ?? run.model_name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{run.display_name && (
|
||||
<p
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
title={run.model_name}
|
||||
>
|
||||
{run.model_name}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
title={run.dataset_name}
|
||||
>
|
||||
{run.dataset_name}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
TrainingRunDeleteResponse,
|
||||
TrainingRunDetailResponse,
|
||||
TrainingRunListResponse,
|
||||
TrainingRunSummary,
|
||||
} from "../types/history";
|
||||
|
||||
async function readError(response: Response): Promise<string> {
|
||||
|
|
@ -57,3 +58,20 @@ export async function deleteTrainingRun(
|
|||
);
|
||||
return parseJson<TrainingRunDeleteResponse>(response);
|
||||
}
|
||||
|
||||
export async function renameTrainingRun(
|
||||
runId: string,
|
||||
displayName: string | null,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TrainingRunSummary> {
|
||||
const response = await authFetch(
|
||||
`/api/train/runs/${encodeURIComponent(runId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ display_name: displayName }),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
return parseJson<TrainingRunSummary>(response);
|
||||
}
|
||||
|
|
|
|||
51
studio/frontend/src/features/training/events.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import type { TrainingRunSummary } from "./types/history";
|
||||
|
||||
type UpdateListener = (run: TrainingRunSummary) => void;
|
||||
type DeleteListener = (runId: string) => void;
|
||||
type ChangedListener = () => void;
|
||||
|
||||
const updateListeners = new Set<UpdateListener>();
|
||||
const deleteListeners = new Set<DeleteListener>();
|
||||
const changedListeners = new Set<ChangedListener>();
|
||||
|
||||
export function onTrainingRunUpdated(fn: UpdateListener): () => void {
|
||||
updateListeners.add(fn);
|
||||
return () => {
|
||||
updateListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
export function onTrainingRunDeleted(fn: DeleteListener): () => void {
|
||||
deleteListeners.add(fn);
|
||||
return () => {
|
||||
deleteListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
export function onTrainingRunsChanged(fn: ChangedListener): () => void {
|
||||
changedListeners.add(fn);
|
||||
return () => {
|
||||
changedListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
export function emitTrainingRunUpdated(run: TrainingRunSummary): void {
|
||||
for (const fn of updateListeners) {
|
||||
fn(run);
|
||||
}
|
||||
}
|
||||
|
||||
export function emitTrainingRunDeleted(runId: string): void {
|
||||
for (const fn of deleteListeners) {
|
||||
fn(runId);
|
||||
}
|
||||
}
|
||||
|
||||
export function emitTrainingRunsChanged(): void {
|
||||
for (const fn of changedListeners) {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { primeNativeNotificationPermission } from "@/lib/native-notifications";
|
|||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { checkDatasetFormat } from "../api/datasets-api";
|
||||
import { emitTrainingRunsChanged } from "../events";
|
||||
import { getTrainingRun } from "../api/history-api";
|
||||
import { buildTrainingStartPayload } from "../api/mappers";
|
||||
import { resetTraining, startTraining, stopTraining } from "../api/train-api";
|
||||
|
|
@ -140,6 +141,7 @@ export function useTrainingActions() {
|
|||
}
|
||||
|
||||
runtimeStore.setStartQueued(response.job_id, response.message);
|
||||
emitTrainingRunsChanged();
|
||||
await syncTrainingRuntimeFromBackend();
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
@ -203,6 +205,7 @@ export function useTrainingActions() {
|
|||
}
|
||||
|
||||
runtimeStore.setStartQueued(response.job_id, response.message);
|
||||
emitTrainingRunsChanged();
|
||||
await syncTrainingRuntimeFromBackend();
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -2,55 +2,195 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { listTrainingRuns } from "../api/history-api";
|
||||
import {
|
||||
onTrainingRunDeleted,
|
||||
onTrainingRunsChanged,
|
||||
onTrainingRunUpdated,
|
||||
} from "../events";
|
||||
import type { TrainingRunSummary } from "../types/history";
|
||||
|
||||
const SIDEBAR_LIMIT = 20;
|
||||
const RUNNING_POLL_MS = 5000;
|
||||
const INITIAL_RETRY_DELAYS_MS = [500, 1500, 3500];
|
||||
const LOAD_FAILURE_TOAST_ID = "training-history-load-failure";
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
return err instanceof DOMException && err.name === "AbortError";
|
||||
}
|
||||
|
||||
export function useTrainingHistorySidebarItems(enabled: boolean) {
|
||||
const [items, setItems] = useState<TrainingRunSummary[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const inFlightRef = useRef(false);
|
||||
|
||||
const fetchRuns = useCallback(async () => {
|
||||
if (inFlightRef.current) {
|
||||
const fetchRuns = useCallback(async (): Promise<void> => {
|
||||
if (controllerRef.current && !controllerRef.current.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
inFlightRef.current = true;
|
||||
try {
|
||||
const result = await listTrainingRuns(SIDEBAR_LIMIT, 0, controller.signal);
|
||||
const result = await listTrainingRuns(
|
||||
SIDEBAR_LIMIT,
|
||||
0,
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setItems(result.runs);
|
||||
setLoaded(true);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
toast.dismiss(LOAD_FAILURE_TOAST_ID);
|
||||
} finally {
|
||||
if (controllerRef.current === controller) {
|
||||
controllerRef.current = null;
|
||||
}
|
||||
inFlightRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Background refresh (rename/delete sync, polling): swallow errors so
|
||||
// transient failures don't spam toasts; the next successful fetch heals.
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await fetchRuns();
|
||||
} catch {
|
||||
// intentionally ignored
|
||||
}
|
||||
}, [fetchRuns]);
|
||||
|
||||
// Initial load: bounded retry-with-backoff, then surface a toast on
|
||||
// final failure with a Retry action so the user isn't stuck staring
|
||||
// at an empty sidebar after F5 if the backend was slow to come up.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
void fetchRuns();
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const showFailureToast = (err: unknown): void => {
|
||||
toast.error("Couldn't load training runs", {
|
||||
id: LOAD_FAILURE_TOAST_ID,
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
action: {
|
||||
label: "Retry",
|
||||
onClick: () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await fetchRuns();
|
||||
} catch (retryErr) {
|
||||
if (isAbortError(retryErr)) {
|
||||
return;
|
||||
}
|
||||
showFailureToast(retryErr);
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const attempt = async (index: number): Promise<void> => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fetchRuns();
|
||||
} catch (err) {
|
||||
if (cancelled || isAbortError(err)) {
|
||||
return;
|
||||
}
|
||||
if (index < INITIAL_RETRY_DELAYS_MS.length) {
|
||||
timer = setTimeout(
|
||||
() => void attempt(index + 1),
|
||||
INITIAL_RETRY_DELAYS_MS[index],
|
||||
);
|
||||
return;
|
||||
}
|
||||
showFailureToast(err);
|
||||
}
|
||||
};
|
||||
|
||||
void attempt(0);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
controllerRef.current?.abort();
|
||||
};
|
||||
}, [enabled, fetchRuns]);
|
||||
|
||||
// Poll while there's a running run, but only when the tab is visible.
|
||||
// Browsers throttle background timers but don't pause them — gating on
|
||||
// visibility avoids hammering the API for tabs left open in the
|
||||
// background, which is common during long training runs.
|
||||
const hasRunning = items.some((r) => r.status === "running");
|
||||
useEffect(() => {
|
||||
if (!enabled || !hasRunning) return;
|
||||
const timer = setInterval(() => {
|
||||
void fetchRuns();
|
||||
}, RUNNING_POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [enabled, hasRunning, fetchRuns]);
|
||||
if (!enabled || !hasRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { items, loaded, refresh: fetchRuns };
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
const start = () => {
|
||||
if (timer !== null) {
|
||||
return;
|
||||
}
|
||||
timer = setInterval(() => void refresh(), RUNNING_POLL_MS);
|
||||
};
|
||||
const stop = () => {
|
||||
if (timer === null) {
|
||||
return;
|
||||
}
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void refresh();
|
||||
start();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
if (document.visibilityState === "visible") {
|
||||
start();
|
||||
}
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
stop();
|
||||
controllerRef.current?.abort();
|
||||
};
|
||||
}, [enabled, hasRunning, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const offUpdated = onTrainingRunUpdated((updated) => {
|
||||
controllerRef.current?.abort();
|
||||
setItems((prev) =>
|
||||
prev.map((run) => (run.id === updated.id ? updated : run)),
|
||||
);
|
||||
});
|
||||
const offDeleted = onTrainingRunDeleted((runId) => {
|
||||
controllerRef.current?.abort();
|
||||
setItems((prev) => prev.filter((run) => run.id !== runId));
|
||||
});
|
||||
const offChanged = onTrainingRunsChanged(() => {
|
||||
controllerRef.current?.abort();
|
||||
void refresh();
|
||||
});
|
||||
return () => {
|
||||
offUpdated();
|
||||
offDeleted();
|
||||
offChanged();
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
return { items, loaded, refresh };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export {
|
|||
export { useTrainingActions } from "./hooks/use-training-actions";
|
||||
export { useTrainingHistorySidebarItems } from "./hooks/use-training-history-sidebar";
|
||||
export { useTrainingRuntimeLifecycle } from "./hooks/use-training-runtime-lifecycle";
|
||||
export { removeTrainingUnloadGuard } from "./hooks/use-training-unload-guard";
|
||||
export { useMaxStepsEpochsToggle } from "./hooks/use-max-steps-epochs-toggle";
|
||||
export { HfDatasetSubsetSplitSelectors } from "./components/hf-dataset-subset-split-selectors";
|
||||
export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-store";
|
||||
|
|
@ -23,6 +24,19 @@ export type {
|
|||
TrainingRunDetailResponse,
|
||||
TrainingRunDeleteResponse,
|
||||
} from "./types/history";
|
||||
export { listTrainingRuns, getTrainingRun, deleteTrainingRun } from "./api/history-api";
|
||||
export {
|
||||
listTrainingRuns,
|
||||
getTrainingRun,
|
||||
deleteTrainingRun,
|
||||
renameTrainingRun,
|
||||
} from "./api/history-api";
|
||||
export {
|
||||
onTrainingRunUpdated,
|
||||
onTrainingRunDeleted,
|
||||
onTrainingRunsChanged,
|
||||
emitTrainingRunUpdated,
|
||||
emitTrainingRunDeleted,
|
||||
emitTrainingRunsChanged,
|
||||
} from "./events";
|
||||
export { parseYamlConfig, serializeConfigToYaml } from "./lib/yaml-config";
|
||||
export { validateTrainingConfig } from "./lib/validation";
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface TrainingRunSummary {
|
|||
status: "running" | "completed" | "stopped" | "error";
|
||||
model_name: string;
|
||||
dataset_name: string;
|
||||
display_name: string | null;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
total_steps: number | null;
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@
|
|||
--card-foreground: oklch(0.1281 0.0179 169.2764);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.1281 0.0179 169.2764);
|
||||
--primary: oklch(0.6929 0.1396 166.5513);
|
||||
--primary: #17b88b;
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.9596 0.0275 167.8295);
|
||||
--secondary-foreground: oklch(0.2868 0.0649 159.9823);
|
||||
|
|
@ -74,21 +74,21 @@
|
|||
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||
--border: oklch(0.9208 0.0101 164.8536);
|
||||
--input: oklch(0.9208 0.0101 164.8536);
|
||||
--ring: oklch(0.6929 0.1396 166.5513);
|
||||
--chart-1: oklch(0.6929 0.1396 166.5513);
|
||||
--ring: #17b88b;
|
||||
--chart-1: #17b88b;
|
||||
--chart-2: oklch(0.694 0.1395 136.6059);
|
||||
--chart-3: oklch(0.7014 0.1193 197.5897);
|
||||
--chart-4: oklch(0.6926 0.1112 346.5775);
|
||||
--chart-5: oklch(0.7497 0.1003 85.0057);
|
||||
--radius: 1.1rem;
|
||||
--sidebar: oklch(0.99 0 0);
|
||||
--sidebar: #f9faf9;
|
||||
--sidebar-foreground: oklch(0.1281 0.0179 169.2764);
|
||||
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
|
||||
--sidebar-primary: #17b88b;
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.96 0.0279 166.55);
|
||||
--sidebar-accent-foreground: oklch(0.2868 0.0649 159.9823);
|
||||
--sidebar-border: oklch(0.9208 0.0101 164.8536);
|
||||
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
|
||||
--sidebar-border: oklch(0.945 0.0101 164.8536);
|
||||
--sidebar-ring: #17b88b;
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
|
||||
--font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans);
|
||||
|
|
@ -121,48 +121,97 @@
|
|||
/* 0px 8px 10px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
|
||||
--tracking-normal: -0.01em;
|
||||
|
||||
/* Hex (not OKLCH) so the rendered surface matches the design mockup pixel-for-pixel. */
|
||||
--nav-fg: #383835;
|
||||
--nav-fg-muted: #858279;
|
||||
--nav-surface-hover: #f0f0f0;
|
||||
--nav-icon-idle: #8f8f8f;
|
||||
--nav-beta-border: #e0ded6;
|
||||
--panel-surface-hover: #ebebeb;
|
||||
/* Right-side chat-parameters panel: matches the chat content
|
||||
surface in both themes — distinction from the left sidebar
|
||||
(#f9faf9) is handled by the left border alone. Tracks
|
||||
`--background` so any future tweaks to the chat surface flow
|
||||
through automatically. */
|
||||
--panel-surface: var(--background);
|
||||
--panel-surface-fg: var(--foreground);
|
||||
--panel-input-surface: #f5f5f5;
|
||||
--panel-input-surface-hover: #efefef;
|
||||
/* Muted gray with a one-step warmer-blue last channel (#779 vs flat #777)
|
||||
so the tone has a faint hue rather than pure neutral — keeps text and
|
||||
sliders quiet but not lifeless. */
|
||||
--panel-surface-fg-muted: #777779;
|
||||
/* Slider track-fill / thumb / hover halo. Decoupled from
|
||||
--panel-surface-fg-muted so the slider can be tuned independently
|
||||
from muted text. Light mode: lighter than the muted-text gray for
|
||||
a softer feel. Dark mode (further down) keeps parity with the
|
||||
muted-text token. */
|
||||
--panel-slider-fg: #9a9a9c;
|
||||
/* Chat-message action icons (assistant action bar, branch picker
|
||||
chevrons + numbers, message-timing token counter, code-block
|
||||
copy/download, user action bar, delete button). One token drives
|
||||
all of them so the row reads as a single coherent control strip.
|
||||
Mid-dark gray on the light surface — visible enough to read as
|
||||
active controls, not so dark that they compete with message
|
||||
text. */
|
||||
--chat-icon-fg: #555555;
|
||||
--chat-icon-fg-hover: var(--foreground);
|
||||
--chat-icon-bg-hover: #ededec;
|
||||
|
||||
/* Standard interactive-icon size for nav, menus, action bars, and
|
||||
in-message code-block actions. Sized one step above body text so
|
||||
icons read as minimally larger than adjacent labels (~14px text).
|
||||
Theme-independent — declared once in :root. */
|
||||
--icon-size: 18px;
|
||||
/* Inset of a centered .size-icon glyph within a 2rem (size-8) action
|
||||
button — i.e. (32px − icon-size) / 2. Use as a negative margin on a
|
||||
chat-message action bar so the leftmost icon's visual edge aligns
|
||||
with the message text edge. Auto-tracks --icon-size. */
|
||||
--icon-btn-inset: calc((2rem - var(--icon-size)) / 2);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Exact palette from apps/studio/index_chat.html mockup. Using hex so the
|
||||
rendered surface matches the mockup pixel-for-pixel — OKLCH conversion
|
||||
drifted ~3% darker and shifted the neutral hue. */
|
||||
--background: #1a1b1e;
|
||||
--foreground: #d4d4d4;
|
||||
--card: #222427;
|
||||
--card-foreground: #d4d4d4;
|
||||
--popover: #222427;
|
||||
--popover-foreground: #d4d4d4;
|
||||
--primary: oklch(0.6929 0.1396 166.5513);
|
||||
--background: #1f2023;
|
||||
--foreground: #ececee;
|
||||
--card: #2d2e32;
|
||||
--card-foreground: #ececee;
|
||||
--popover: #2d2e32;
|
||||
--popover-foreground: #ececee;
|
||||
--primary: #17b88b;
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: #2e3035;
|
||||
--secondary-foreground: #d4d4d4;
|
||||
--secondary-foreground: #ececee;
|
||||
--muted: #2e3035;
|
||||
--muted-foreground: #999999;
|
||||
--accent: #2e3035;
|
||||
--accent-foreground: #d4d4d4;
|
||||
--accent-foreground: #ececee;
|
||||
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||
--border: #2e3035;
|
||||
/* --input one step lighter than --muted so form borders stay visible on
|
||||
muted surfaces (right config panel) and keep subtle contrast on card. */
|
||||
/* --border / --input one step lighter than --muted so outlines and form
|
||||
borders stay visible on muted surfaces (right config panel, export
|
||||
tiles, quant chips) and keep subtle contrast on card. */
|
||||
--border: #3a3d42;
|
||||
--input: #3a3d42;
|
||||
--ring: oklch(0.6929 0.1396 166.5513);
|
||||
--ring: #17b88b;
|
||||
--chart-1: oklch(0.7511 0.1407 166.2284);
|
||||
--chart-2: oklch(0.75 0.14 136.5572);
|
||||
--chart-3: oklch(0.7554 0.1285 197.339);
|
||||
--chart-4: oklch(0.7503 0.1199 346.7805);
|
||||
--chart-5: oklch(0.799 0.1196 84.6633);
|
||||
--sidebar: #222427;
|
||||
--sidebar-foreground: #d4d4d4;
|
||||
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
|
||||
--sidebar: #18181a;
|
||||
--sidebar-foreground: #ececee;
|
||||
--sidebar-primary: #17b88b;
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: #2e3035;
|
||||
--sidebar-accent-foreground: #d4d4d4;
|
||||
--sidebar-border: #2e3035;
|
||||
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
|
||||
--sidebar-accent: #2f2f31;
|
||||
--sidebar-accent-foreground: #ececee;
|
||||
--sidebar-border: #2d2d2f;
|
||||
--sidebar-ring: #17b88b;
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--radius: 0.625rem;
|
||||
--font-sans: Geist, ui-sans-serif, sans-serif, system-ui;
|
||||
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
|
|
@ -181,6 +230,34 @@
|
|||
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);
|
||||
|
||||
--nav-fg: #c7c7c4;
|
||||
--nav-fg-muted: #96979b;
|
||||
--nav-surface-hover: #2e2e30;
|
||||
--nav-icon-idle: #5c5c5c;
|
||||
--nav-beta-border: #3a3c3f;
|
||||
--panel-surface-hover: #3a3c42;
|
||||
/* Right-side chat-parameters panel: matches the chat content
|
||||
surface in both themes — distinction from the left sidebar
|
||||
(#18181a, the deepest surface) is handled by the left border
|
||||
alone. Tracks `--background` so any future tweaks to the chat
|
||||
surface flow through automatically. */
|
||||
--panel-surface: var(--background);
|
||||
--panel-surface-fg: var(--foreground);
|
||||
--panel-input-surface: #2a2b2e;
|
||||
--panel-input-surface-hover: #2e3033;
|
||||
/* Soft neutral gray for muted text and sliders. Pure-ish #ababab
|
||||
reads as quiet on the dark panel without going colored. */
|
||||
--panel-surface-fg-muted: #ababab;
|
||||
/* Dark-mode slider tone matches muted text — user wants the dark
|
||||
theme slider unchanged from the previous behavior. */
|
||||
--panel-slider-fg: #ababab;
|
||||
/* Chat-message action icons. A touch lighter than the previous
|
||||
#b8b8b8 so the icons read clearly without going near pure white;
|
||||
hover restores full --foreground for affordance. */
|
||||
--chat-icon-fg: #d8d8d8;
|
||||
--chat-icon-fg-hover: var(--foreground);
|
||||
--chat-icon-bg-hover: #2d2e32;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
|
|
@ -251,6 +328,20 @@
|
|||
/*--shadow-opacity: var(--shadow-opacity);*/
|
||||
/*--color-shadow-color: var(--shadow-color);*/
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-nav-fg: var(--nav-fg);
|
||||
--color-nav-fg-muted: var(--nav-fg-muted);
|
||||
--color-nav-surface-hover: var(--nav-surface-hover);
|
||||
--color-nav-icon-idle: var(--nav-icon-idle);
|
||||
--color-nav-beta-border: var(--nav-beta-border);
|
||||
--color-panel-surface-hover: var(--panel-surface-hover);
|
||||
--color-panel-surface: var(--panel-surface);
|
||||
--color-panel-surface-fg: var(--panel-surface-fg);
|
||||
--color-panel-surface-fg-muted: var(--panel-surface-fg-muted);
|
||||
--color-chat-icon-fg: var(--chat-icon-fg);
|
||||
--color-chat-icon-fg-hover: var(--chat-icon-fg-hover);
|
||||
--color-chat-icon-bg-hover: var(--chat-icon-bg-hover);
|
||||
|
||||
--animate-pulse: pulse var(--duration) ease-out infinite;
|
||||
|
||||
@keyframes pulse {
|
||||
|
|
@ -362,6 +453,300 @@
|
|||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Dark mode loosens tracking to offset optical bloom on dark surfaces. */
|
||||
.tracking-nav {
|
||||
letter-spacing: 0.015em;
|
||||
}
|
||||
.dark .tracking-nav {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.nav-icon-btn {
|
||||
@apply inline-flex h-7 w-7 items-center justify-center rounded-[10px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring;
|
||||
}
|
||||
|
||||
/* Standard icon size — drives every nav/menu/action-bar icon
|
||||
(left sidebar, app-user menu, settings tabs, chat config toggle,
|
||||
right-panel close, chat message action bars, code-block actions).
|
||||
Pulls from --icon-size so a single edit retunes them all. */
|
||||
.size-icon {
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
}
|
||||
|
||||
/* Pins Inter across themes; parent `font-heading` resolves to Geist in dark. */
|
||||
.nav-badge {
|
||||
font-family: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.sidebar-nav-btn {
|
||||
color: var(--nav-fg);
|
||||
}
|
||||
.sidebar-nav-btn:hover,
|
||||
.sidebar-nav-btn[data-active="true"],
|
||||
.sidebar-nav-btn[data-state="open"],
|
||||
.group\/recent-item:hover .sidebar-nav-btn,
|
||||
.group\/recent-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
|
||||
.group\/run-item:hover .sidebar-nav-btn,
|
||||
.group\/run-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn {
|
||||
background-color: var(--nav-surface-hover) !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
.dark .sidebar-nav-btn:hover,
|
||||
.dark .sidebar-nav-btn[data-active="true"],
|
||||
.dark .sidebar-nav-btn[data-state="open"],
|
||||
.dark .group\/recent-item:hover .sidebar-nav-btn,
|
||||
.dark .group\/recent-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
|
||||
.dark .group\/run-item:hover .sidebar-nav-btn,
|
||||
.dark .group\/run-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.sidebar-row-action {
|
||||
@apply absolute top-0 bottom-0 right-0 inline-flex items-center justify-end pl-2 pr-1.5 opacity-0 pointer-events-none outline-none;
|
||||
}
|
||||
.sidebar-row-action[data-state="open"] {
|
||||
@apply opacity-100 pointer-events-auto;
|
||||
}
|
||||
.sidebar-row-action-glyph {
|
||||
@apply inline-flex size-6 items-center justify-center rounded-[10px] text-sidebar-foreground/55;
|
||||
}
|
||||
|
||||
/* Branch picker chevron buttons sit beside action bar icon buttons
|
||||
(size-8, rounded-[10px]). Height + radius match for visual
|
||||
alignment, but width is tighter so the small chevron glyph reads
|
||||
as a compact control rather than a full-size icon button. */
|
||||
.aui-branch-chevron-btn {
|
||||
@apply inline-flex h-8 w-6 cursor-pointer items-center justify-center rounded-[10px] p-0 text-chat-icon-fg transition-colors hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent;
|
||||
}
|
||||
.sidebar-row-action:hover .sidebar-row-action-glyph,
|
||||
.sidebar-row-action[data-state="open"] .sidebar-row-action-glyph {
|
||||
@apply bg-nav-surface-hover text-nav-fg;
|
||||
}
|
||||
.dark .sidebar-row-action:hover .sidebar-row-action-glyph,
|
||||
.dark .sidebar-row-action[data-state="open"] .sidebar-row-action-glyph {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-sticky-label {
|
||||
@apply sticky top-0 z-20 rounded-none bg-sidebar pt-0 pb-1.5 pl-[18px] pr-4 text-[13px]! font-medium normal-case tracking-[0.04em] text-nav-fg-muted focus-visible:ring-0! focus-visible:outline-none shadow-[0_-8px_0_0_var(--sidebar)] transition-shadow duration-150;
|
||||
}
|
||||
.sidebar-sticky-label.is-scrolled {
|
||||
@apply shadow-[0_-8px_0_0_var(--sidebar),0_0.5px_0_0_var(--sidebar-border)];
|
||||
}
|
||||
|
||||
/* Neutral panel input surface — sidesteps the green cast on
|
||||
`--input` / `--border` (both have a small chroma at hue ~165 in
|
||||
light mode). Same value drives the preset input pill, the system
|
||||
prompt button, and the chat-template textarea so all three read
|
||||
as one quiet gray family, matching the focused-edit-number tint. */
|
||||
.panel-input-group {
|
||||
@apply !h-9 min-h-9 min-w-0 items-stretch gap-0 rounded-[10px] pr-0 transition-colors focus-within:ring-0 focus-within:shadow-none;
|
||||
border: 0 !important;
|
||||
background-color: var(--panel-input-surface);
|
||||
}
|
||||
.panel-input-group:has([data-slot="input-group-control"]:focus-visible) {
|
||||
border: 0 !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Neutral surface for the larger panel text containers — system
|
||||
prompt preview button and chat-template textarea — so they share
|
||||
the same gray as the preset input pill and don't pick up the
|
||||
theme's slight green-cast `--input`. */
|
||||
.panel-text-surface {
|
||||
@apply rounded-[20px] border-0 transition-colors;
|
||||
background-color: var(--panel-input-surface);
|
||||
}
|
||||
.panel-text-surface:hover {
|
||||
background-color: var(--panel-input-surface-hover);
|
||||
}
|
||||
|
||||
/* Sidebar sliders: soft neutral grays — active fill and thumb stay
|
||||
in the same neutral family as the panel surface so the controls
|
||||
read as quiet, modern, and uncluttered. Flat (no shadow), small
|
||||
thumb, no ring. The track's translucent neutral adapts to either
|
||||
theme; the fill/thumb pick a mid-gray with enough contrast to
|
||||
read on the panel without competing with text. */
|
||||
/* Inactive track: barely-there alpha so it reads as a faint hint
|
||||
rather than a visible bar — the active fill carries the value,
|
||||
the track just suggests the slider's extent. Same alpha both
|
||||
themes; the black/white base flips automatically per theme. */
|
||||
.panel-slider [data-slot="slider-track"] {
|
||||
height: 0.25rem !important;
|
||||
background-color: rgb(0 0 0 / 0.025) !important;
|
||||
}
|
||||
.dark .panel-slider [data-slot="slider-track"] {
|
||||
background-color: rgb(255 255 255 / 0.025) !important;
|
||||
}
|
||||
/* Sliders in the right-side parameters panel.
|
||||
*
|
||||
* Color: every interactive surface (active fill, thumb body, thumb
|
||||
* border, hover/press halo) resolves through a single token —
|
||||
* `--panel-surface-fg-muted` — so the slider always belongs to the
|
||||
* same gray family as the panel's muted text. Theme switching and
|
||||
* future tone tweaks happen in one place.
|
||||
*
|
||||
* Pressure feedback: only the halo expresses interaction. The
|
||||
* shared <Slider /> component (components/ui/slider.tsx) applies
|
||||
* `hover:scale-110`, `active:scale-95`, `hover:ring-4`, and
|
||||
* `shadow-sm` to the thumb via Tailwind utilities. Suppressing
|
||||
* `transform` and `box-shadow` on the base rule prevents those from
|
||||
* competing with the halo's transition — without that, two
|
||||
* animations run on different durations/curves and the interaction
|
||||
* reads as jittery.
|
||||
*
|
||||
* Track-press detection: Radix's slider thumb only exposes
|
||||
* `data-disabled` and `data-orientation` (verified against the
|
||||
* package source) — there is no active-state attribute on the
|
||||
* thumb. We anchor the press selector on the slider root
|
||||
* (`.panel-slider:active`), which receives `:active` for any
|
||||
* pointer-down inside the slider, including presses that start on
|
||||
* the track. This is the only DOM-faithful way to show the halo
|
||||
* during a track-press without patching the shared component.
|
||||
*
|
||||
* Halo lifecycle: deliberately no `:focus` — pointer focus persists
|
||||
* after release and would leave a stale halo. `:focus-visible`
|
||||
* keeps keyboard navigation accessible (Tab + arrows). */
|
||||
.panel-slider .bg-primary {
|
||||
background-color: var(--panel-slider-fg) !important;
|
||||
}
|
||||
.panel-slider [data-slot="slider-thumb"] {
|
||||
width: 0.875rem !important;
|
||||
height: 0.875rem !important;
|
||||
background-color: var(--panel-slider-fg) !important;
|
||||
border-color: var(--panel-slider-fg) !important;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
transition: box-shadow 140ms ease-out !important;
|
||||
}
|
||||
.panel-slider [data-slot="slider-thumb"]:hover,
|
||||
.panel-slider [data-slot="slider-thumb"]:focus-visible,
|
||||
.panel-slider:active [data-slot="slider-thumb"] {
|
||||
box-shadow: 0 0 0 10px
|
||||
color-mix(in srgb, var(--panel-slider-fg) 18%, transparent) !important;
|
||||
}
|
||||
/* Active press: slightly larger / more opaque ring than passive
|
||||
hover, so the haptic reads stronger when the user is actively
|
||||
manipulating the value. */
|
||||
.panel-slider:active [data-slot="slider-thumb"] {
|
||||
box-shadow: 0 0 0 12px
|
||||
color-mix(in srgb, var(--panel-slider-fg) 22%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Inline numeric input — used for slider values and Context Length.
|
||||
Designed to read as *editable text* rather than a pill button so
|
||||
it doesn't mirror the Select/dropdown components on the panel.
|
||||
Default: transparent box, no border, no ring, sized by the
|
||||
`size` HTML attribute (each consumer picks 6 / 8 / etc. chars).
|
||||
Hover/focus: very light bg fade-in to signal editability — just
|
||||
enough to read as interactive, not enough to compete with the
|
||||
slider row's quiet aesthetic. */
|
||||
.panel-number-input {
|
||||
@apply h-7 rounded-md border-0 bg-transparent px-1.5 text-right text-[13px]! font-medium tabular-nums text-nav-fg shadow-none transition-colors hover:bg-black/[0.04] focus:bg-black/[0.05] focus-visible:ring-0! focus-visible:outline-none md:text-[13px]!;
|
||||
}
|
||||
.dark .panel-number-input {
|
||||
@apply hover:bg-white/[0.04] focus:bg-white/[0.06];
|
||||
}
|
||||
|
||||
/* Switch — unsloth-green track when active, slider-gray thumb in
|
||||
both states. Keeps the on-state recognizable as an "engaged"
|
||||
primary control while the moving thumb sits in the same neutral
|
||||
palette as the panel sliders, tying every control in the panel
|
||||
to a single gray family. Unchecked track keeps shadcn's default
|
||||
bg-input for the standard off affordance. */
|
||||
.panel-switch[data-state="checked"] {
|
||||
background-color: var(--primary) !important;
|
||||
}
|
||||
.panel-switch [data-slot="switch-thumb"] {
|
||||
background-color: var(--panel-slider-fg) !important;
|
||||
}
|
||||
.panel-switch[data-state="checked"] [data-slot="switch-thumb"] {
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Compact tooltip — small black pill with white text. Used for short
|
||||
hover labels on chat-area icon buttons (Copy, Edit, Delete,
|
||||
Refresh, More, code-block actions) and the panel's info-icon
|
||||
hints. Corner radius is fixed at 8px so it tracks with the
|
||||
underlying icon button corners (also 8px) — keeps the tooltip
|
||||
visually anchored to its trigger rather than reading as a much
|
||||
larger floating pill. */
|
||||
.tooltip-compact {
|
||||
@apply rounded-[10px] border-transparent bg-black px-2 py-1.5 text-[11px] font-medium leading-snug text-white shadow-md;
|
||||
}
|
||||
|
||||
/* Rich tooltip — used for the context-usage and token-counter
|
||||
popups (multi-row metric breakdowns). Same black surface as the
|
||||
compact tooltips so chat-area popovers feel like one family.
|
||||
Corner radius matches the user-profile dropdown in the left
|
||||
sidebar (14px) so panel-level menu surfaces share a single
|
||||
roundness. Uses the heading font with tracking for the structured
|
||||
content. Same in both themes. */
|
||||
.tooltip-rich {
|
||||
@apply rounded-[16px] border-transparent bg-black px-4 py-3 font-heading tracking-wide text-white shadow-[0_8px_28px_-6px_rgba(0,0,0,0.32)];
|
||||
}
|
||||
/* Row-label color override — the popups reuse the existing prose
|
||||
`text-muted-foreground` class. Fixed light gray on the black
|
||||
surface keeps the label clearly legible while staying distinct
|
||||
from the values (full white). */
|
||||
.tooltip-rich .text-muted-foreground {
|
||||
color: #b1b1b1 !important;
|
||||
}
|
||||
.tooltip-rich .border-border\/40 {
|
||||
border-color: rgb(255 255 255 / 0.12) !important;
|
||||
}
|
||||
|
||||
.app-user-menu [data-slot="dropdown-menu-item"] {
|
||||
height: 32px;
|
||||
padding: 0 0.625rem !important;
|
||||
gap: 8.5px !important;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
font-size: 14.5px;
|
||||
line-height: 19px;
|
||||
letter-spacing: 0.015em;
|
||||
color: var(--nav-fg);
|
||||
}
|
||||
.dark .app-user-menu [data-slot="dropdown-menu-item"] {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.app-user-menu [data-slot="dropdown-menu-item"] svg {
|
||||
width: 19px !important;
|
||||
height: 19px !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-user-menu [data-slot="dropdown-menu-item"]:focus {
|
||||
background-color: var(--nav-surface-hover);
|
||||
color: #000;
|
||||
}
|
||||
.dark .app-user-menu [data-slot="dropdown-menu-item"]:focus {
|
||||
color: #fff;
|
||||
}
|
||||
.app-user-menu [data-slot="dropdown-menu-item"]:focus * {
|
||||
color: #000 !important;
|
||||
}
|
||||
.dark .app-user-menu [data-slot="dropdown-menu-item"]:focus * {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.menu-flat-destructive {
|
||||
--destructive: #dc4848;
|
||||
}
|
||||
.dark .menu-flat-destructive {
|
||||
--destructive: #ed7878;
|
||||
}
|
||||
.app-user-menu [data-slot="dropdown-menu-item"][data-variant="destructive"],
|
||||
.app-user-menu [data-slot="dropdown-menu-item"][data-variant="destructive"]:focus {
|
||||
color: var(--destructive);
|
||||
}
|
||||
.app-user-menu [data-slot="dropdown-menu-item"][data-variant="destructive"]:focus {
|
||||
background-color: color-mix(in oklab, var(--destructive) 10%, transparent);
|
||||
}
|
||||
.app-user-menu [data-slot="dropdown-menu-item"][data-variant="destructive"]:focus * {
|
||||
color: var(--destructive) !important;
|
||||
}
|
||||
|
||||
/* Elevated surface shadow (use ring-* for borders) */
|
||||
.shadow-border {
|
||||
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
|
|
@ -374,7 +759,32 @@
|
|||
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.menu-soft-surface,
|
||||
.menu-soft-surface-up {
|
||||
--menu-soft-edge: rgba(0, 0, 0, 0.14);
|
||||
--menu-soft-shadow: rgba(0, 0, 0, 0.18);
|
||||
--menu-soft-offset-y: 8px;
|
||||
--menu-soft-blur: 28px;
|
||||
--menu-soft-spread: -6px;
|
||||
@apply bg-popover text-popover-foreground;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--menu-soft-edge),
|
||||
0 var(--menu-soft-offset-y) var(--menu-soft-blur)
|
||||
var(--menu-soft-spread) var(--menu-soft-shadow);
|
||||
}
|
||||
.menu-soft-surface-up {
|
||||
--menu-soft-offset-y: -6px;
|
||||
--menu-soft-spread: -8px;
|
||||
}
|
||||
.dark .menu-soft-surface,
|
||||
.dark .menu-soft-surface-up {
|
||||
--menu-soft-edge: rgba(255, 255, 255, 0.07);
|
||||
--menu-soft-shadow: rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.chat-composer-surface {
|
||||
@apply relative flex w-full flex-col rounded-[24px] bg-background dark:bg-card px-1 pt-2 outline-none transition-shadow;
|
||||
font-family: var(--font-sans);
|
||||
border: 1px solid oklch(0.93 0 0 / 1);
|
||||
background-clip: padding-box;
|
||||
box-shadow:
|
||||
|
|
@ -384,8 +794,28 @@
|
|||
}
|
||||
|
||||
.dark .chat-composer-surface {
|
||||
border-color: #2e3035;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
|
||||
border-color: #34363a;
|
||||
box-shadow: 0 -6px 36px -14px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.composer-pill-btn {
|
||||
@apply flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors hover:bg-primary/10 dark:hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40;
|
||||
}
|
||||
.composer-pill-btn[data-active="true"] {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.composer-input {
|
||||
@apply mb-1 min-h-12 w-full resize-none overflow-y-auto bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0;
|
||||
}
|
||||
|
||||
.composer-action-wrapper {
|
||||
@apply relative mx-2 mb-2 flex items-center justify-between;
|
||||
}
|
||||
|
||||
.composer-footer-note {
|
||||
@apply mt-1.5 text-center text-[11px] tracking-[0.04em] text-muted-foreground;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Fine-tuning Studio: equal default height, expandable when needed (md+) */
|
||||
|
|
@ -426,6 +856,7 @@
|
|||
[data-streamdown="code-block"] {
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 1.5rem;
|
||||
/* Wide lines must scroll inside the thread column, not widen past the composer (flex min-width:auto). */
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
|
|
@ -469,6 +900,45 @@
|
|||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Normalize the trailing margin of the last element inside an
|
||||
assistant message so the gap above the action bar is the same
|
||||
regardless of whether the response ends with a paragraph
|
||||
(margin-bottom: 0 by Tailwind preflight) or a streamdown block
|
||||
like a code fence (margin-bottom: 1rem from `my-4`). Browser
|
||||
block layout doesn't collapse trailing margin into a sibling
|
||||
container, so we zero it explicitly along the deepest
|
||||
`:last-child` path. Streamdown wraps content in several
|
||||
nested divs, so code blocks land 4–5 levels deep — the chain
|
||||
walks that depth without using a generic descendant `:last-child`
|
||||
(which would also zero last-paragraph-in-list margins). The
|
||||
visible gap is then driven solely by the footer's own `mt-*`. */
|
||||
.aui-assistant-message-content > *:last-child,
|
||||
.aui-assistant-message-content > *:last-child > *:last-child,
|
||||
.aui-assistant-message-content > *:last-child > *:last-child > *:last-child,
|
||||
.aui-assistant-message-content > *:last-child > *:last-child > *:last-child > *:last-child,
|
||||
.aui-assistant-message-content > *:last-child > *:last-child > *:last-child > *:last-child > *:last-child,
|
||||
.aui-assistant-message-content > *:last-child > *:last-child > *:last-child > *:last-child > *:last-child > *:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* The streamdown code-block wrapper carries `my-4` (16px top + 16px
|
||||
bottom margin). The bottom margin is what stretches the gap
|
||||
between the code-block box and the action-bar below it on a
|
||||
trailing code block. We zero `margin-bottom` on every code block
|
||||
inside an assistant message body. CSS margin collapsing handles
|
||||
the non-trailing case correctly: when a code block is followed by
|
||||
a paragraph (or any block with `my-4` mt), the rendered gap is
|
||||
`max(prev.mb, next.mt)` — so removing the code block's `mb` still
|
||||
leaves the next element's `mt-4` as the visible spacer. The only
|
||||
case actually affected is the trailing position (no next element),
|
||||
where `mb=0` collapses the gap to just the footer's `mt-2`,
|
||||
matching a text-trailing message. The wrapper's own
|
||||
`padding-bottom` is preserved, so the last line of code keeps its
|
||||
natural breathing room inside the box. */
|
||||
.aui-assistant-message-content [data-streamdown="code-block"] {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* Keep monospace for code fences and inline code (not KaTeX). */
|
||||
.aui-thread-root [data-streamdown="code-block"] pre,
|
||||
.aui-thread-root [data-streamdown="code-block"] code {
|
||||
|
|
@ -491,7 +961,38 @@
|
|||
/* Streamdown `pre` uses `dark:bg-[var(--shiki-dark-bg,...)]`; keep one surface on the outer shell. */
|
||||
--shiki-dark-bg: transparent;
|
||||
background: var(--color-code-block);
|
||||
border: 1px solid oklch(1 0 0 / 0.07);
|
||||
border: 1px solid oklch(1 0 0 / 0.08);
|
||||
}
|
||||
|
||||
/* Streamdown code-block stability hardening.
|
||||
*
|
||||
* Two streamdown internals cause a visible "reload"-style flicker on
|
||||
* trailing code blocks the moment streaming ends. Both are addressable
|
||||
* purely in CSS without patching the library:
|
||||
*
|
||||
* 1. `content-visibility: auto` + `contain-intrinsic-size: auto 200px`
|
||||
* (set inline by streamdown's <ot> wrapper). The IntersectionObserver
|
||||
* that gates content-visibility flips rendered height between the
|
||||
* 200px placeholder and the actual code-block height as the block
|
||||
* sits near the viewport edge during stream finalization. The
|
||||
* height jump is small but visible, and the rendering optimization
|
||||
* is unnecessary for chat content (thread length is bounded). We
|
||||
* force `visible` to keep the rendered height of code blocks fully
|
||||
* determined by their actual content at all times.
|
||||
*
|
||||
* 2. `[data-sd-animate]` (`sd-fadeIn`, 150ms). Streamdown wraps each
|
||||
* streaming text segment in a span carrying this attribute. When
|
||||
* shiki re-renders the code body at stream end, those wrapper
|
||||
* spans get re-keyed and the fade animation replays across the
|
||||
* whole block at once — exactly the visual that reads as the chat
|
||||
* area "reloading for a frame." We disable the animation only
|
||||
* inside code blocks; prose token fade-in elsewhere is untouched. */
|
||||
.aui-thread-root [data-streamdown="code-block"] {
|
||||
content-visibility: visible !important;
|
||||
contain-intrinsic-size: none !important;
|
||||
}
|
||||
.aui-thread-root [data-streamdown="code-block"] [data-sd-animate] {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -509,15 +1010,34 @@
|
|||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track-piece {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.5 0 0 / 0.54);
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button {
|
||||
|
|
@ -539,20 +1059,51 @@
|
|||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* Marker class applied only to actual streaming-thread viewports
|
||||
(see ThreadPrimitive.Viewport in components/assistant-ui/thread.tsx).
|
||||
Scoped separately from `.aui-thread-viewport` because that class is
|
||||
reused elsewhere for shared scrollbar styling on non-streaming scroll
|
||||
areas; the stabilizer must only attach to viewports the
|
||||
useIntentAwareAutoScroll hook actually drives. */
|
||||
.aui-stream-viewport {
|
||||
/* Scroll stabilizer: compensates for transient scrollHeight shrinks
|
||||
(most visibly, shiki re-highlighting a trailing code block the
|
||||
instant streaming ends). The useIntentAwareAutoScroll hook sets
|
||||
this variable to the exact pixel amount of any content shrink
|
||||
observed while the follow window is active; that padding keeps
|
||||
scrollHeight monotonic, so the browser never auto-clamps scrollTop,
|
||||
so no jump is ever painted. Released back to 0 as content
|
||||
genuinely grows past its prior high-water mark, and on user
|
||||
detach so the bottom stays flush when they come back. */
|
||||
padding-bottom: var(--aui-scroll-stabilizer, 0px);
|
||||
}
|
||||
|
||||
.dark .aui-thread-viewport {
|
||||
scrollbar-color: oklch(0.67 0 0 / 0.5) var(--sidebar);
|
||||
scrollbar-color: oklch(0.72 0 0 / 0.25) #23252a;
|
||||
}
|
||||
|
||||
.dark .aui-thread-viewport::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.72 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.aui-thread-viewport::-webkit-scrollbar-track {
|
||||
background: var(--sidebar);
|
||||
}
|
||||
|
||||
.dark .aui-thread-viewport::-webkit-scrollbar-track {
|
||||
background: #23252a;
|
||||
}
|
||||
|
||||
[data-sidebar="content"] {
|
||||
scrollbar-color: oklch(0.5 0 0 / 0.22) transparent;
|
||||
scrollbar-color: oklch(0.5 0 0 / 0.22) var(--sidebar);
|
||||
}
|
||||
|
||||
.dark [data-sidebar="content"] {
|
||||
scrollbar-color: oklch(0.72 0 0 / 0.25) transparent;
|
||||
scrollbar-color: oklch(0.72 0 0 / 0.25) var(--sidebar);
|
||||
}
|
||||
|
||||
[data-sidebar="content"]::-webkit-scrollbar-track {
|
||||
background: var(--sidebar);
|
||||
}
|
||||
|
||||
[data-sidebar="content"]::-webkit-scrollbar-thumb {
|
||||
|
|
|
|||
|
|
@ -434,6 +434,16 @@ def is_github_api_url(url: str | None) -> bool:
|
|||
|
||||
def is_retryable_url_error(exc: Exception) -> bool:
|
||||
if isinstance(exc, urllib.error.HTTPError):
|
||||
# GitHub returns 403 (not the standard 429) when the API rate
|
||||
# limit is hit. Anonymous calls share a 60-req/hour bucket per
|
||||
# runner IP, which CI fleets can exhaust trivially. Treat 403
|
||||
# against api.github.com as retryable so we get one or two
|
||||
# backoff cycles before the source-build fallback fires; honour
|
||||
# Retry-After / X-RateLimit-Reset in sleep_backoff for accurate
|
||||
# waits. Real 403s on other hosts (private artefact downloads,
|
||||
# auth failures) stay non-retryable.
|
||||
if exc.code == 403:
|
||||
return is_github_api_url(getattr(exc, "url", None))
|
||||
return exc.code in RETRYABLE_HTTP_STATUS
|
||||
if isinstance(exc, urllib.error.URLError):
|
||||
return True
|
||||
|
|
@ -444,10 +454,43 @@ def is_retryable_url_error(exc: Exception) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
_RATE_LIMIT_WAIT_CAP_SECONDS = 60.0
|
||||
|
||||
|
||||
def _http_error_retry_delay(exc: Exception) -> float | None:
|
||||
"""Extract a recommended wait from rate-limit headers on a 403/429.
|
||||
|
||||
Returns None when no header is present or the indicated wait is
|
||||
longer than _RATE_LIMIT_WAIT_CAP_SECONDS (in which case the caller
|
||||
should not block on it -- the source-build fallback is faster).
|
||||
"""
|
||||
if not isinstance(exc, urllib.error.HTTPError):
|
||||
return None
|
||||
headers = getattr(exc, "headers", None)
|
||||
if headers is None:
|
||||
return None
|
||||
retry_after = headers.get("Retry-After")
|
||||
if retry_after and retry_after.strip().isdigit():
|
||||
wait = float(retry_after.strip())
|
||||
return wait if wait <= _RATE_LIMIT_WAIT_CAP_SECONDS else None
|
||||
rate_reset = headers.get("X-RateLimit-Reset")
|
||||
if rate_reset and rate_reset.strip().isdigit():
|
||||
wait = float(rate_reset.strip()) - time.time()
|
||||
if 0.0 < wait <= _RATE_LIMIT_WAIT_CAP_SECONDS:
|
||||
return wait + 1.0 # +1s of slack so the bucket is fresh
|
||||
return None
|
||||
|
||||
|
||||
def sleep_backoff(
|
||||
attempt: int, *, base_delay: float = HTTP_FETCH_BASE_DELAY_SECONDS
|
||||
attempt: int,
|
||||
*,
|
||||
base_delay: float = HTTP_FETCH_BASE_DELAY_SECONDS,
|
||||
exc: Exception | None = None,
|
||||
) -> None:
|
||||
delay = base_delay * (2 ** max(attempt - 1, 0))
|
||||
header_delay = _http_error_retry_delay(exc) if exc is not None else None
|
||||
if header_delay is not None:
|
||||
delay = max(delay, header_delay)
|
||||
delay += random.uniform(0.0, 0.2)
|
||||
time.sleep(delay)
|
||||
|
||||
|
|
@ -833,7 +876,7 @@ def download_bytes(
|
|||
if attempt >= attempts or not is_retryable_url_error(exc):
|
||||
raise
|
||||
log(f"fetch failed ({attempt}/{attempts}) for {url}: {exc}; retrying")
|
||||
sleep_backoff(attempt)
|
||||
sleep_backoff(attempt, exc = exc)
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
|
|
@ -931,7 +974,7 @@ def download_file(url: str, destination: Path) -> None:
|
|||
log(
|
||||
f"download failed ({attempt}/{HTTP_FETCH_ATTEMPTS}) for {url}: {exc}; retrying"
|
||||
)
|
||||
sleep_backoff(attempt)
|
||||
sleep_backoff(attempt, exc = exc)
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
|
|
|
|||
|
|
@ -530,12 +530,33 @@ function Write-LlamaFailureLog {
|
|||
Write-Host " | $line" -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
# Mirror the plain (no ANSI) form of step/substep messages to the
|
||||
# OS-level stdout handle when a parent is consuming our stdout via
|
||||
# a pipe (CI `tee`, Python subprocess.PIPE, CREATE_NO_WINDOW grandchild).
|
||||
# Write-Host on PS 5.1 routes through $Host.UI / the Information
|
||||
# stream, neither of which propagates reliably across the
|
||||
# install.ps1 -> unsloth.exe -> python -> powershell.exe ->
|
||||
# setup.ps1 process chain. [Console]::Out always lands on the OS
|
||||
# stdout file handle. Gated on IsOutputRedirected so the
|
||||
# interactive-console path keeps the colorized Write-Host output
|
||||
# only (no double-print).
|
||||
function Write-StudioStdoutMirror {
|
||||
param([Parameter(Mandatory = $true)][string]$Line)
|
||||
try {
|
||||
if ([Console]::IsOutputRedirected) {
|
||||
[Console]::Out.WriteLine($Line)
|
||||
[Console]::Out.Flush()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function step {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Label,
|
||||
[Parameter(Mandatory = $true)][string]$Value,
|
||||
[string]$Color = "Green"
|
||||
)
|
||||
$padded = if ($Label.Length -ge 15) { $Label.Substring(0, 15) } else { $Label.PadRight(15) }
|
||||
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
|
||||
$dim = Get-StudioAnsi Dim
|
||||
$rst = Get-StudioAnsi Reset
|
||||
|
|
@ -546,10 +567,8 @@ function step {
|
|||
'DarkGray' { Get-StudioAnsi Dim }
|
||||
default { Get-StudioAnsi Ok }
|
||||
}
|
||||
$padded = if ($Label.Length -ge 15) { $Label.Substring(0, 15) } else { $Label.PadRight(15) }
|
||||
Write-Host (" {0}{1}{2}{3}{4}{2}" -f $dim, $padded, $rst, $val, $Value)
|
||||
} else {
|
||||
$padded = if ($Label.Length -ge 15) { $Label.Substring(0, 15) } else { $Label.PadRight(15) }
|
||||
Write-Host (" {0}" -f $padded) -NoNewline -ForegroundColor DarkGray
|
||||
$fc = switch ($Color) {
|
||||
'Green' { 'DarkGreen' }
|
||||
|
|
@ -560,6 +579,7 @@ function step {
|
|||
}
|
||||
Write-Host $Value -ForegroundColor $fc
|
||||
}
|
||||
Write-StudioStdoutMirror (" {0}{1}" -f $padded, $Value)
|
||||
}
|
||||
|
||||
function substep {
|
||||
|
|
@ -581,6 +601,7 @@ function substep {
|
|||
}
|
||||
Write-Host (" {0,-15}{1}" -f "", $Message) -ForegroundColor $fc
|
||||
}
|
||||
Write-StudioStdoutMirror (" {0,-15}{1}" -f "", $Message)
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
|
|
|
|||
214
tests/_zoo_aggressive_cuda_spoof.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
# Auto-generated by .github/workflows/consolidated-tests-ci.yml.
|
||||
# Aggressive CUDA spoof for the consolidated CPU-only CI job. Extends
|
||||
# tests/conftest.py:84-141's import-time harness with deeper patches that
|
||||
# unblock more patch_* functions and unsloth_zoo init paths on a GPU-less
|
||||
# runner. Imported by every shim test file in this workflow before any
|
||||
# unsloth / unsloth_zoo / transformers import.
|
||||
#
|
||||
# Design: only no-op or value-returning patches. We do NOT replace tensor
|
||||
# allocators. The single exception is `pin_memory=True` kwarg dropping,
|
||||
# which converts a hard CUDA-required call into a CPU-OK call -- the
|
||||
# intent of pin_memory is a CUDA-host fast-copy, which simply has no
|
||||
# meaning on this runner; downgrading silently is the right behavior here.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from typing import Any
|
||||
|
||||
|
||||
def apply() -> None:
|
||||
"""Apply the spoof. Idempotent: calling again has no effect."""
|
||||
import torch
|
||||
|
||||
if getattr(torch.cuda, "_unsloth_consolidated_spoof", False):
|
||||
return
|
||||
|
||||
# ----- device probes (cheap, value-returning) -------------------------
|
||||
torch.cuda.is_available = lambda: True
|
||||
torch.cuda.device_count = lambda: 1
|
||||
torch.cuda.current_device = lambda: 0
|
||||
torch.cuda.is_initialized = lambda: True
|
||||
torch.cuda.set_device = lambda *a, **k: None
|
||||
torch.cuda.synchronize = lambda *a, **k: None
|
||||
torch.cuda.empty_cache = lambda *a, **k: None
|
||||
torch.cuda.get_device_name = lambda *a, **k: "NVIDIA A100-SPOOFED"
|
||||
torch.cuda.get_device_capability = lambda *a, **k: (8, 0)
|
||||
torch.cuda.is_bf16_supported = lambda *a, **k: True
|
||||
torch.cuda._is_in_bad_fork = lambda *a, **k: False # type: ignore[attr-defined]
|
||||
|
||||
class _Props:
|
||||
name = "NVIDIA A100-SPOOFED"
|
||||
major = 8
|
||||
minor = 0
|
||||
total_memory = 80 * 1024**3
|
||||
multi_processor_count = 108
|
||||
is_integrated = False
|
||||
is_multi_gpu_board = False
|
||||
|
||||
torch.cuda.get_device_properties = lambda *a, **k: _Props() # type: ignore[assignment]
|
||||
|
||||
# ----- cudart() wrapper -----------------------------------------------
|
||||
class _CudaRt:
|
||||
@staticmethod
|
||||
def cudaMemGetInfo(device: int = 0):
|
||||
return (0, 80 * 1024**3)
|
||||
|
||||
@staticmethod
|
||||
def cudaGetDeviceCount(*_a, **_k):
|
||||
return 0 # Not used on the spoof path
|
||||
|
||||
@staticmethod
|
||||
def cudaSetDevice(*_a, **_k):
|
||||
return 0
|
||||
|
||||
torch.cuda.cudart = lambda: _CudaRt() # type: ignore[assignment]
|
||||
|
||||
# ----- memory module --------------------------------------------------
|
||||
try:
|
||||
import torch.cuda.memory as _cuda_memory # type: ignore
|
||||
|
||||
_cuda_memory.mem_get_info = lambda *a, **k: (0, 80 * 1024**3)
|
||||
_cuda_memory.memory_stats = lambda *a, **k: {}
|
||||
_cuda_memory.memory_allocated = lambda *a, **k: 0
|
||||
_cuda_memory.max_memory_allocated = lambda *a, **k: 0
|
||||
_cuda_memory.memory_reserved = lambda *a, **k: 0
|
||||
_cuda_memory.max_memory_reserved = lambda *a, **k: 0
|
||||
_cuda_memory.reset_peak_memory_stats = lambda *a, **k: None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ----- nvtx no-op stub ------------------------------------------------
|
||||
nvtx_stub = types.ModuleType("torch.cuda.nvtx")
|
||||
nvtx_stub.range_push = lambda *a, **k: None # type: ignore[attr-defined]
|
||||
nvtx_stub.range_pop = lambda *a, **k: None # type: ignore[attr-defined]
|
||||
nvtx_stub.mark = lambda *a, **k: None # type: ignore[attr-defined]
|
||||
sys.modules.setdefault("torch.cuda.nvtx", nvtx_stub)
|
||||
torch.cuda.nvtx = nvtx_stub # type: ignore[attr-defined]
|
||||
|
||||
# ----- random API ----------------------------------------------------
|
||||
# CRITICAL: torch.manual_seed() internally calls torch.cuda.manual_seed_all(),
|
||||
# so routing the cuda seed APIs back through torch.manual_seed would
|
||||
# infinite-recurse (observed as RecursionError in run #8 cells 2/3 of the
|
||||
# consolidated CI matrix). No-op them: callers that explicitly seed CUDA
|
||||
# have already paid the cost of seeding CPU via torch.manual_seed; the
|
||||
# CUDA-side seeding has no meaning on a GPU-less runner.
|
||||
torch.cuda.manual_seed = lambda *a, **k: None # type: ignore[assignment]
|
||||
torch.cuda.manual_seed_all = lambda *a, **k: None # type: ignore[assignment]
|
||||
# rng_state APIs: return a CPU-shaped placeholder and accept anything for
|
||||
# set; do NOT route through torch.set_rng_state / get_rng_state -- those
|
||||
# operate on the CPU RNG directly and are independent of the cuda surface.
|
||||
import torch as _t
|
||||
|
||||
_empty_rng_state = _t.empty(0, dtype = _t.uint8)
|
||||
torch.cuda.get_rng_state = lambda *a, **k: _empty_rng_state.clone() # type: ignore[assignment]
|
||||
torch.cuda.set_rng_state = lambda *a, **k: None # type: ignore[assignment]
|
||||
torch.cuda.get_rng_state_all = lambda *a, **k: [_empty_rng_state.clone()] # type: ignore[attr-defined]
|
||||
torch.cuda.set_rng_state_all = lambda *a, **k: None # type: ignore[attr-defined]
|
||||
torch.cuda.initial_seed = lambda *a, **k: 0 # type: ignore[assignment]
|
||||
torch.cuda.seed = lambda *a, **k: None # type: ignore[assignment]
|
||||
torch.cuda.seed_all = lambda *a, **k: None # type: ignore[assignment]
|
||||
|
||||
# ----- Stream / Event no-op classes -----------------------------------
|
||||
class _NoopStream:
|
||||
def __init__(self, *a, **k): ...
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def synchronize(self, *a, **k): ...
|
||||
def wait_stream(self, *a, **k): ...
|
||||
def query(self):
|
||||
return True
|
||||
|
||||
class _NoopEvent:
|
||||
def __init__(self, *a, **k): ...
|
||||
def record(self, *a, **k): ...
|
||||
def wait(self, *a, **k): ...
|
||||
def query(self):
|
||||
return True
|
||||
|
||||
def synchronize(self, *a, **k): ...
|
||||
def elapsed_time(self, *a, **k):
|
||||
return 0.0
|
||||
|
||||
torch.cuda.Stream = _NoopStream # type: ignore[assignment]
|
||||
torch.cuda.Event = _NoopEvent # type: ignore[assignment]
|
||||
torch.cuda.stream = lambda s: s if s is not None else _NoopStream() # type: ignore[assignment]
|
||||
torch.cuda.current_stream = lambda *a, **k: _NoopStream() # type: ignore[assignment]
|
||||
torch.cuda.default_stream = lambda *a, **k: _NoopStream() # type: ignore[assignment]
|
||||
|
||||
# ----- pin_memory drop -------------------------------------------------
|
||||
# `torch.empty(..., pin_memory=True)` and friends raise on a CPU-only
|
||||
# build. Strip the kwarg — pin_memory has no meaning here.
|
||||
for _name in (
|
||||
"empty",
|
||||
"zeros",
|
||||
"ones",
|
||||
"empty_like",
|
||||
"zeros_like",
|
||||
"ones_like",
|
||||
"rand",
|
||||
"randn",
|
||||
"randint",
|
||||
):
|
||||
_orig = getattr(torch, _name, None)
|
||||
if _orig is None:
|
||||
continue
|
||||
|
||||
def _wrap(*args: Any, _orig = _orig, **kwargs: Any):
|
||||
kwargs.pop("pin_memory", None)
|
||||
return _orig(*args, **kwargs)
|
||||
|
||||
setattr(torch, _name, _wrap)
|
||||
|
||||
# Tensor.pin_memory() instance method: also a no-op (return self).
|
||||
if hasattr(torch.Tensor, "pin_memory"):
|
||||
torch.Tensor.pin_memory = lambda self, *a, **k: self # type: ignore[assignment]
|
||||
if hasattr(torch.Tensor, "is_pinned"):
|
||||
torch.Tensor.is_pinned = lambda self, *a, **k: False # type: ignore[assignment]
|
||||
|
||||
# ----- amp.GradScaler: use the real one if torch ships a CPU-friendly
|
||||
# path, else stub. Newer torch ships torch.amp.GradScaler that handles
|
||||
# CPU; torch.cuda.amp.GradScaler is a wrapper. Both should work; just
|
||||
# guard against import error.
|
||||
try:
|
||||
import torch.cuda.amp # type: ignore
|
||||
except Exception:
|
||||
cuda_amp = types.ModuleType("torch.cuda.amp")
|
||||
|
||||
class _StubScaler:
|
||||
def __init__(self, *a, **k): ...
|
||||
def scale(self, x):
|
||||
return x
|
||||
|
||||
def step(self, opt):
|
||||
opt.step()
|
||||
|
||||
def update(self, *a, **k): ...
|
||||
def unscale_(self, *a, **k): ...
|
||||
def get_scale(self):
|
||||
return 1.0
|
||||
|
||||
def is_enabled(self):
|
||||
return False
|
||||
|
||||
def state_dict(self):
|
||||
return {}
|
||||
|
||||
def load_state_dict(self, *a, **k): ...
|
||||
|
||||
cuda_amp.GradScaler = _StubScaler # type: ignore[attr-defined]
|
||||
sys.modules.setdefault("torch.cuda.amp", cuda_amp)
|
||||
torch.cuda.amp = cuda_amp # type: ignore[attr-defined]
|
||||
|
||||
# ----- Sentinel ------------------------------------------------------
|
||||
torch.cuda._unsloth_consolidated_spoof = True # type: ignore[attr-defined]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
apply()
|
||||
print("CUDA spoof applied.")
|
||||