Merge branch 'main' into image-generation
This commit is contained in:
commit
e8d3cfe2b5
140 changed files with 7764 additions and 1360 deletions
|
|
@ -1,8 +0,0 @@
|
|||
# Commits listed here are skipped by `git blame` so that bulk, whitespace-only
|
||||
# changes don't obscure the real authorship of a line.
|
||||
#
|
||||
# GitHub honors this file automatically. To use it locally, run once:
|
||||
# git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||
|
||||
# chore(studio/frontend): normalize line endings to LF
|
||||
c50b8ab910f5aa56dd7ae0022d2c7b96bfe3384a
|
||||
75
.github/workflows/consolidated-tests-ci.yml
vendored
75
.github/workflows/consolidated-tests-ci.yml
vendored
|
|
@ -2204,12 +2204,13 @@ jobs:
|
|||
pip install -e "$RUNNER_TEMP/unsloth-zoo" --no-deps
|
||||
pip show unsloth_zoo
|
||||
|
||||
- name: llama.cpp install via unsloth_zoo.llama_cpp + `llama-cli --help` smoke
|
||||
- name: llama.cpp install via unsloth_zoo.llama_cpp + CLI `--help` smoke
|
||||
# Exercise the canonical `unsloth_zoo.llama_cpp.install_llama_cpp`
|
||||
# flow that GGUF export uses at runtime: clone ggml-org/llama.cpp
|
||||
# into ~/.unsloth/llama.cpp, build the LLAMA_CPP_TARGETS list
|
||||
# (llama-quantize, llama-cli, llama-mtmd-cli, llama-gguf-split,
|
||||
# llama-server) via cmake, then run `llama-cli --help`.
|
||||
# llama-server) via cmake, then run `--help` on whichever CLI
|
||||
# inference binary the build actually produced.
|
||||
#
|
||||
# This replaces the previous "download upstream prebuilt zip"
|
||||
# approach, which silently exited 0 with the message
|
||||
|
|
@ -2218,6 +2219,18 @@ jobs:
|
|||
# matched their current asset names). The build path is the same
|
||||
# one Unsloth users hit in production via `model.save_pretrained_gguf`.
|
||||
#
|
||||
# We do NOT hard-require `llama-cli` specifically: upstream
|
||||
# ggml-org/llama.cpp moved the cli/server/ui targets behind the
|
||||
# `LLAMA_BUILD_SERVER` cmake option (tools/CMakeLists.txt) and the
|
||||
# set of binaries that survive a given checkout drifts over time
|
||||
# (e.g. a recent build root shipped llama-server + llama-quantize
|
||||
# + llama-diffusion-cli but no llama-cli). The durable contract is
|
||||
# "install_llama_cpp produced a working CLI inference binary AND a
|
||||
# working quantizer", so we --help-probe the first of
|
||||
# llama-cli / llama-mtmd-cli / llama-server that exists. If a
|
||||
# future llama.cpp restores llama-cli it is first in the list and
|
||||
# is preferred, so this stays backwards compatible.
|
||||
#
|
||||
# Wall-time budget: ~3-5 min cold, dominated by cmake build of
|
||||
# 5 targets on the runner's 4 cores. Apt-package install is
|
||||
# handled by `install_llama_cpp` itself via its
|
||||
|
|
@ -2252,8 +2265,9 @@ jobs:
|
|||
print(f"Build targets: {LLAMA_CPP_TARGETS}")
|
||||
# install_llama_cpp returns (quantizer_path, converter_script_path).
|
||||
# The quantizer's directory is the `llama.cpp` install root, which
|
||||
# also holds llama-cli after build/bin/llama-* gets copied up
|
||||
# (llama_cpp.py:867-871).
|
||||
# also holds the CLI inference binaries after build/bin/llama-* gets
|
||||
# copied up (llama_cpp.py:1450-1454; on Windows they stay in
|
||||
# build/bin/Release/).
|
||||
quantizer, converter = install_llama_cpp(print_output=True)
|
||||
assert quantizer and os.path.exists(quantizer), (
|
||||
f"install_llama_cpp returned quantizer={quantizer!r} but file missing"
|
||||
|
|
@ -2262,25 +2276,54 @@ jobs:
|
|||
f"install_llama_cpp returned converter={converter!r} but missing"
|
||||
)
|
||||
install_root = os.path.dirname(quantizer)
|
||||
cli = os.path.join(install_root, "llama-cli")
|
||||
assert os.path.exists(cli), (
|
||||
f"llama-cli not found at {cli!r} after build. Build root contents: "
|
||||
f"{sorted(p for p in os.listdir(install_root) if p.startswith('llama-'))[:20]}"
|
||||
)
|
||||
assert os.access(cli, os.X_OK), f"{cli!r} not executable"
|
||||
# `llama-cli --help` exits non-zero on some builds; the contract
|
||||
# is that recognizable help text appears on stdout/stderr.
|
||||
is_windows = sys.platform == "win32"
|
||||
exe = ".exe" if is_windows else ""
|
||||
# Search both the copied-up root and the Windows build/bin/Release/
|
||||
# location the quantizer might already live in.
|
||||
search_dirs = [install_root]
|
||||
win_release = os.path.join(install_root, "build", "bin", "Release")
|
||||
if win_release not in search_dirs:
|
||||
search_dirs.append(win_release)
|
||||
# Any of these proves a working llama.cpp CLI inference binary was
|
||||
# built. Order = preference: llama-cli is canonical (restored first
|
||||
# if upstream brings it back), then the multimodal CLI, then the
|
||||
# server (always built whenever cli would be, behind LLAMA_BUILD_SERVER).
|
||||
cli_names = [f"llama-cli{exe}", f"llama-mtmd-cli{exe}", f"llama-server{exe}"]
|
||||
cli = None
|
||||
cli_name = None
|
||||
for name in cli_names:
|
||||
for d in search_dirs:
|
||||
candidate = os.path.join(d, name)
|
||||
if os.path.exists(candidate) and (is_windows or os.access(candidate, os.X_OK)):
|
||||
cli, cli_name = candidate, name
|
||||
break
|
||||
if cli is not None:
|
||||
break
|
||||
if cli is None:
|
||||
found = []
|
||||
for d in search_dirs:
|
||||
if os.path.isdir(d):
|
||||
found += [p for p in os.listdir(d) if p.startswith("llama-")]
|
||||
raise AssertionError(
|
||||
f"No CLI inference binary ({', '.join(cli_names)}) found after "
|
||||
f"build in {search_dirs}. Build root contents: {sorted(set(found))[:20]}"
|
||||
)
|
||||
print(f"Using CLI inference binary: {cli_name} -> {cli}")
|
||||
# `--help` exits non-zero on some builds; the contract is that
|
||||
# recognizable help text appears on stdout/stderr. llama-server
|
||||
# exposes a different flag set than llama-cli, so accept its
|
||||
# tokens too (e.g. --host / --port / "server").
|
||||
proc = subprocess.run(
|
||||
[cli, "--help"], capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
combined = (proc.stdout or "") + (proc.stderr or "")
|
||||
print("--- llama-cli --help (first 30 lines) ---")
|
||||
print(f"--- {cli_name} --help (first 30 lines) ---")
|
||||
print("\n".join(combined.splitlines()[:30]))
|
||||
assert any(
|
||||
tok in combined.lower()
|
||||
for tok in ("usage", "--help", "--model", "-m,")
|
||||
for tok in ("usage", "--help", "--model", "-m,", "--host", "--port", "server")
|
||||
), (
|
||||
f"llama-cli --help produced no recognizable help text. "
|
||||
f"{cli_name} --help produced no recognizable help text. "
|
||||
f"exit={proc.returncode}\nstdout: {proc.stdout[:400]!r}\n"
|
||||
f"stderr: {proc.stderr[:400]!r}"
|
||||
)
|
||||
|
|
@ -2296,7 +2339,7 @@ jobs:
|
|||
f"stderr: {q.stderr[:400]!r}"
|
||||
)
|
||||
print(
|
||||
f"\nOK: install_llama_cpp produced a working llama-cli at {cli} "
|
||||
f"\nOK: install_llama_cpp produced a working {cli_name} at {cli} "
|
||||
f"and llama-quantize at {quantizer}."
|
||||
)
|
||||
PY
|
||||
|
|
|
|||
24
.github/workflows/local-agent-guides-ci.yml
vendored
24
.github/workflows/local-agent-guides-ci.yml
vendored
|
|
@ -64,6 +64,12 @@ concurrency:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
# Secret handling on pull_request: these jobs check out and run PR-controlled code
|
||||
# (install.sh, .github/scripts/**), so HF_TOKEN (an external HF credential) is gated
|
||||
# off pull_request at each step below -- public GGUF repos still download anonymously.
|
||||
# GH_TOKEN (GITHUB_TOKEN) is kept: it is the job-scoped contents:read token and
|
||||
# install_llama_prebuilt.py needs it for the GitHub releases API (else 403s).
|
||||
|
||||
env:
|
||||
# Determinism precedent (studio-inference-smoke.yml): temp 0 + fixed seed.
|
||||
UNSLOTH_SEED: '3407'
|
||||
|
|
@ -134,7 +140,8 @@ jobs:
|
|||
id: download-gguf
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
|
|
@ -150,7 +157,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
@ -335,7 +343,8 @@ jobs:
|
|||
id: download-gguf
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
|
|
@ -351,7 +360,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
@ -508,7 +518,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -524,7 +535,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
|
|||
15
.github/workflows/mlx-ci.yml
vendored
15
.github/workflows/mlx-ci.yml
vendored
|
|
@ -241,7 +241,8 @@ jobs:
|
|||
# non-zero binary exit is an Unsloth/Studio bug.
|
||||
- name: Studio prebuilt llama.cpp install + GGUF inference (Mac M1)
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && 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
|
||||
|
|
@ -332,7 +333,8 @@ jobs:
|
|||
# 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 }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
run: |
|
||||
mkdir -p mlx_workdir
|
||||
|
|
@ -348,7 +350,8 @@ jobs:
|
|||
# the saved dir.
|
||||
- name: MLX export round-trip — RELOAD LoRA (fresh process)
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
run: |
|
||||
python tests/studio/run_real_mlx_smoke.py reload \
|
||||
|
|
@ -357,7 +360,8 @@ jobs:
|
|||
|
||||
- name: MLX export round-trip — RELOAD merged_16bit (fresh process)
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
run: |
|
||||
python tests/studio/run_real_mlx_smoke.py reload \
|
||||
|
|
@ -372,7 +376,8 @@ jobs:
|
|||
# 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 }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && 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 \
|
||||
|
|
|
|||
6
.github/workflows/studio-api-smoke.yml
vendored
6
.github/workflows/studio-api-smoke.yml
vendored
|
|
@ -83,7 +83,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -100,7 +101,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
|
|||
1
.github/workflows/studio-backend-ci.yml
vendored
1
.github/workflows/studio-backend-ci.yml
vendored
|
|
@ -226,6 +226,7 @@ jobs:
|
|||
tests/sh/test_studio_home_node_dir.sh \
|
||||
tests/sh/test_system_node_readonly.sh \
|
||||
tests/sh/test_nvcc_meets_llama_minimum.sh \
|
||||
tests/sh/test_resolve_cuda_archs.sh \
|
||||
tests/sh/test_tauri_install_exit_order.sh \
|
||||
tests/sh/test_torch_constraint.sh \
|
||||
tests/sh/test_torch_flavor.sh; do
|
||||
|
|
|
|||
18
.github/workflows/studio-inference-smoke.yml
vendored
18
.github/workflows/studio-inference-smoke.yml
vendored
|
|
@ -97,7 +97,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -114,7 +115,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
@ -364,7 +366,8 @@ jobs:
|
|||
id: download-gguf
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
|
|
@ -380,7 +383,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
@ -845,7 +849,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -863,7 +868,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
|
|||
6
.github/workflows/studio-mac-api-smoke.yml
vendored
6
.github/workflows/studio-mac-api-smoke.yml
vendored
|
|
@ -68,7 +68,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -85,7 +86,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
|
|||
18
.github/workflows/studio-mac-inference-smoke.yml
vendored
18
.github/workflows/studio-mac-inference-smoke.yml
vendored
|
|
@ -91,7 +91,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -110,7 +111,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
@ -346,7 +348,8 @@ jobs:
|
|||
id: download-gguf
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
|
|
@ -363,7 +366,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
@ -725,7 +729,8 @@ jobs:
|
|||
# Authenticated + parallel: shared macos-14 NAT egress stalls
|
||||
# multi-GB anonymous downloads.
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
|
|
@ -752,7 +757,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
|
|||
6
.github/workflows/studio-mac-ui-smoke.yml
vendored
6
.github/workflows/studio-mac-ui-smoke.yml
vendored
|
|
@ -68,7 +68,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -85,7 +86,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
|
|||
|
|
@ -62,7 +62,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
@ -74,7 +75,8 @@ jobs:
|
|||
- name: First update should be a no-op (prebuilt already validated)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update.log
|
||||
|
|
@ -93,7 +95,8 @@ jobs:
|
|||
- name: Second update must also be a no-op
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update2.log
|
||||
|
|
|
|||
6
.github/workflows/studio-ui-smoke.yml
vendored
6
.github/workflows/studio-ui-smoke.yml
vendored
|
|
@ -82,7 +82,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -99,7 +100,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
|
|||
9
.github/workflows/studio-update-smoke.yml
vendored
9
.github/workflows/studio-update-smoke.yml
vendored
|
|
@ -71,7 +71,8 @@ jobs:
|
|||
# prebuilt path falls back to source build.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
@ -86,7 +87,8 @@ jobs:
|
|||
# idempotency regressed.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update.log
|
||||
|
|
@ -109,7 +111,8 @@ jobs:
|
|||
# the first one.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update2.log
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -124,7 +125,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 captures Write-Host (Information stream) output;
|
||||
|
|
|
|||
|
|
@ -127,7 +127,8 @@ jobs:
|
|||
# described above (outcome != success).
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -179,7 +180,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 captures Write-Host (Information stream) output;
|
||||
|
|
@ -476,7 +478,8 @@ jobs:
|
|||
id: download-gguf
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
|
|
@ -524,7 +527,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 captures Write-Host (Information stream) output;
|
||||
|
|
@ -906,7 +910,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -956,7 +961,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 captures Write-Host (Information stream) output;
|
||||
|
|
@ -1299,7 +1305,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -1376,7 +1383,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
|
|
|||
|
|
@ -91,7 +91,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -155,7 +156,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 redirects ALL PowerShell streams (stdout, stderr,
|
||||
|
|
|
|||
|
|
@ -133,7 +133,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 captures Write-Host (Information stream) output;
|
||||
|
|
@ -180,7 +181,8 @@ jobs:
|
|||
- name: First update should be a no-op (prebuilt already validated)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update.log
|
||||
|
|
@ -199,7 +201,8 @@ jobs:
|
|||
- name: Second update must also be a no-op
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update2.log
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.17
|
||||
rev: v0.15.18
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ unsloth studio -p 8888
|
|||
```
|
||||
For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally.
|
||||
|
||||
For a secure HTTPS link instead of a raw network port, use `unsloth studio --secure`. Studio stays bound to localhost and is served only through a free Cloudflare HTTPS tunnel (it fails closed if the tunnel can't start, so the raw port is never exposed).
|
||||
To reach Studio over HTTPS, use `unsloth studio --secure`. Studio stays bound to localhost and is reached only through a free Cloudflare tunnel, which publishes it at a public `https://*.trycloudflare.com` URL (it fails closed if the tunnel can't start, so the raw port is never exposed). This makes Studio reachable from the internet, so anyone with the link and API key can use it and run code: keep your API key private (see Remote access below).
|
||||
|
||||
#### Docker
|
||||
Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Run:
|
||||
|
|
|
|||
|
|
@ -2595,6 +2595,7 @@ exit 0
|
|||
step "launch" "to start later, run:"
|
||||
substep "unsloth studio -p 8888"
|
||||
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
|
||||
Write-Host ""
|
||||
}
|
||||
} else {
|
||||
|
|
@ -2615,6 +2616,7 @@ exit 0
|
|||
substep "unsloth studio -p 8888"
|
||||
}
|
||||
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
install.sh
62
install.sh
|
|
@ -1534,17 +1534,15 @@ _maybe_reroute_strixhalo_to_2404() {
|
|||
_maybe_reroute_strixhalo_to_2404 || true
|
||||
|
||||
# ── Check system dependencies ──
|
||||
# cmake and git are needed by unsloth studio setup to build the GGUF inference
|
||||
# engine (llama.cpp). build-essential and libcurl-dev are also needed on Linux.
|
||||
# cmake/git are only needed to *build* llama.cpp from source. Studio downloads a
|
||||
# prebuilt by default, and setup.sh self-skips the source build when they're
|
||||
# absent -- so macOS doesn't block on cmake (requiring it would force a manual
|
||||
# Homebrew install). Linux keeps requiring them; its package manager has them.
|
||||
tauri_log "STEP" "Checking system dependencies"
|
||||
MISSING=""
|
||||
|
||||
command -v cmake >/dev/null 2>&1 || MISSING="$MISSING cmake"
|
||||
command -v git >/dev/null 2>&1 || MISSING="$MISSING git"
|
||||
|
||||
case "$OS" in
|
||||
macos)
|
||||
# Xcode Command Line Tools provide the C/C++ compiler
|
||||
# Xcode Command Line Tools provide the C/C++ compiler and git.
|
||||
if ! xcode-select -p >/dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "==> Xcode Command Line Tools are required."
|
||||
|
|
@ -1553,8 +1551,19 @@ case "$OS" in
|
|||
echo " After the installation completes, please re-run this script."
|
||||
exit 1
|
||||
fi
|
||||
# cmake is only needed for a source build; the default prebuilt path
|
||||
# doesn't use it, so its absence is not fatal -- no Homebrew prerequisite.
|
||||
if command -v cmake >/dev/null 2>&1; then
|
||||
step "deps" "all system dependencies found"
|
||||
else
|
||||
step "deps" "using prebuilt llama.cpp (cmake not found)" "$C_WARN"
|
||||
substep "Install cmake only if you want a source build: brew install cmake"
|
||||
fi
|
||||
;;
|
||||
linux|wsl)
|
||||
MISSING=""
|
||||
command -v cmake >/dev/null 2>&1 || MISSING="$MISSING cmake"
|
||||
command -v git >/dev/null 2>&1 || MISSING="$MISSING git"
|
||||
# curl or wget is needed for downloads; check both
|
||||
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
|
||||
MISSING="$MISSING curl"
|
||||
|
|
@ -1562,27 +1571,12 @@ case "$OS" in
|
|||
command -v gcc >/dev/null 2>&1 || MISSING="$MISSING build-essential"
|
||||
# libcurl dev headers for llama.cpp HTTPS support
|
||||
command -v curl-config >/dev/null 2>&1 || MISSING="$MISSING libcurl4-openssl-dev"
|
||||
;;
|
||||
esac
|
||||
|
||||
MISSING=$(echo "$MISSING" | sed 's/^ *//')
|
||||
|
||||
if [ -n "$MISSING" ]; then
|
||||
echo ""
|
||||
step "deps" "missing: $MISSING" "$C_WARN"
|
||||
substep "These are needed to build the GGUF inference engine."
|
||||
|
||||
case "$OS" in
|
||||
macos)
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
echo ""
|
||||
echo " Homebrew is required to install them."
|
||||
echo " Install Homebrew from https://brew.sh then re-run this script."
|
||||
exit 1
|
||||
fi
|
||||
brew install $MISSING </dev/null
|
||||
;;
|
||||
linux|wsl)
|
||||
MISSING=$(echo "$MISSING" | sed 's/^ *//')
|
||||
if [ -n "$MISSING" ]; then
|
||||
echo ""
|
||||
step "deps" "missing: $MISSING" "$C_WARN"
|
||||
substep "These are needed to build the GGUF inference engine."
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
_smart_apt_install $MISSING
|
||||
else
|
||||
|
|
@ -1597,12 +1591,12 @@ if [ -n "$MISSING" ]; then
|
|||
echo " openSUSE: sudo zypper install cmake git gcc gcc-c++ make libcurl-devel"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
echo ""
|
||||
else
|
||||
step "deps" "all system dependencies found"
|
||||
fi
|
||||
echo ""
|
||||
else
|
||||
step "deps" "all system dependencies found"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Install uv ──
|
||||
tauri_log "STEP" "Installing uv package manager"
|
||||
|
|
@ -3160,6 +3154,7 @@ if [ -t 1 ]; then
|
|||
step "launch" "to start later, run:"
|
||||
substep "unsloth studio -p 8888"
|
||||
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
|
||||
echo ""
|
||||
;;
|
||||
esac
|
||||
|
|
@ -3181,5 +3176,6 @@ else
|
|||
substep "unsloth studio -p 8888"
|
||||
fi
|
||||
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
|
||||
echo ""
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ studio = [
|
|||
"*.sh",
|
||||
"*.ps1",
|
||||
"*.bat",
|
||||
"node_prebuilt_pins.json",
|
||||
"frontend/dist/**/*",
|
||||
"frontend/*.json",
|
||||
"frontend/*.ts",
|
||||
|
|
@ -56,6 +57,7 @@ studio = [
|
|||
"backend/requirements/**/*",
|
||||
"backend/plugins/**/*",
|
||||
"backend/assets/**/*.jinja",
|
||||
"backend/assets/**/*.html",
|
||||
"backend/core/data_recipe/oxc-validator/*.json",
|
||||
"backend/core/data_recipe/oxc-validator/*.mjs",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -72,13 +72,6 @@
|
|||
"severity": "CRITICAL",
|
||||
"evidence": "Archive: L317: a['TarFileType'] = tarfile.open(fileobj=_fileW,mode='w')\nNetwork: L330: x['SocketType'] = _socket = socket.socket()"
|
||||
},
|
||||
{
|
||||
"package": "evaluate",
|
||||
"file": "evaluate/utils/file_utils.py",
|
||||
"check": "C2 polling/beaconing loop detected",
|
||||
"severity": "CRITICAL",
|
||||
"evidence": "L261: while True:"
|
||||
},
|
||||
{
|
||||
"package": "execnet",
|
||||
"file": "execnet/gateway_base.py",
|
||||
|
|
@ -401,27 +394,6 @@
|
|||
"severity": "CRITICAL",
|
||||
"evidence": "Base64: L488: decoded_bytes = base64.b64decode(base64_encoded)\nSubprocess: L80: return subprocess.call(['which', name], | L100: p = subprocess.Popen(['pbcopy', 'w'], | L105: p = subprocess.Popen(['pbpaste', 'r'],"
|
||||
},
|
||||
{
|
||||
"package": "pytest",
|
||||
"file": "_pytest/_py/path.py",
|
||||
"check": "Downloads and executes remote code",
|
||||
"severity": "CRITICAL",
|
||||
"evidence": "L1153: exec(f.read(), mod.__dict__)"
|
||||
},
|
||||
{
|
||||
"package": "pytest",
|
||||
"file": "_pytest/capture.py",
|
||||
"check": "Reverse shell / bind shell pattern",
|
||||
"severity": "CRITICAL",
|
||||
"evidence": "L483: os.dup2(self.targetfd_invalid, targetfd) | L522: os.dup2(self.tmpfile.fileno(), self.targetfd) | L532: os.dup2(self.targetfd_save, self.targetfd)"
|
||||
},
|
||||
{
|
||||
"package": "pytest",
|
||||
"file": "_pytest/config/__init__.py",
|
||||
"check": "Reverse shell / bind shell pattern",
|
||||
"severity": "CRITICAL",
|
||||
"evidence": "L260: os.dup2(devnull, sys.stdout.fileno())"
|
||||
},
|
||||
{
|
||||
"package": "python-dateutil",
|
||||
"file": "dateutil/__init__.py",
|
||||
|
|
@ -527,6 +499,34 @@
|
|||
"severity": "CRITICAL",
|
||||
"evidence": "L13: __import__(__package__ + '.linalg') | L14: __import__(__package__ + '.fft')"
|
||||
},
|
||||
{
|
||||
"package": "scipy",
|
||||
"file": "scipy/_external/array_api_compat/cupy/__init__.py",
|
||||
"check": "Downloads and executes remote code",
|
||||
"severity": "CRITICAL",
|
||||
"evidence": "L12: __import__(__package__ + '.linalg') | L13: __import__(__package__ + '.fft')"
|
||||
},
|
||||
{
|
||||
"package": "scipy",
|
||||
"file": "scipy/_external/array_api_compat/dask/array/__init__.py",
|
||||
"check": "Downloads and executes remote code",
|
||||
"severity": "CRITICAL",
|
||||
"evidence": "L16: __import__(__package__ + '.linalg') | L17: __import__(__package__ + '.fft')"
|
||||
},
|
||||
{
|
||||
"package": "scipy",
|
||||
"file": "scipy/_external/array_api_compat/numpy/__init__.py",
|
||||
"check": "Downloads and executes remote code",
|
||||
"severity": "CRITICAL",
|
||||
"evidence": "L23: __import__(__package__ + \".linalg\") | L25: __import__(__package__ + \".fft\")"
|
||||
},
|
||||
{
|
||||
"package": "scipy",
|
||||
"file": "scipy/_external/array_api_compat/torch/__init__.py",
|
||||
"check": "Downloads and executes remote code",
|
||||
"severity": "CRITICAL",
|
||||
"evidence": "L13: __import__(__package__ + '.linalg') | L14: __import__(__package__ + '.fft')"
|
||||
},
|
||||
{
|
||||
"package": "sentencepiece",
|
||||
"file": "sentencepiece/__init__.py",
|
||||
|
|
@ -814,6 +814,13 @@
|
|||
"severity": "CRITICAL",
|
||||
"evidence": "L67: input_gguf=\"/tmp/in.gguf\","
|
||||
},
|
||||
{
|
||||
"package": "unsloth-zoo",
|
||||
"file": "tests/test_mlx_save_export_regressions.py",
|
||||
"check": "Writes to /tmp and executes (staged dropper)",
|
||||
"severity": "CRITICAL",
|
||||
"evidence": "L164: temporary_location=\"/tmp/ignored\","
|
||||
},
|
||||
{
|
||||
"package": "unsloth-zoo",
|
||||
"file": "tests/test_upstream_pinned_symbols_transformers.py",
|
||||
|
|
@ -933,13 +940,6 @@
|
|||
"severity": "HIGH",
|
||||
"evidence": "Key: L187: \"-----BEGIN PUBLIC KEY-----\", | L188: \"-----BEGIN RSA PUBLIC KEY-----\",\nNetwork: L225: http_client: httpx.AsyncClient | None = None, | L411: else httpx.AsyncClient(timeout=httpx.Timeout(10.0))"
|
||||
},
|
||||
{
|
||||
"package": "hypothesis",
|
||||
"file": "hypothesis/internal/scrutineer.py",
|
||||
"check": "Anti-analysis/sandbox evasion + suspicious behavior",
|
||||
"severity": "HIGH",
|
||||
"evidence": "Anti: L76: return sys.gettrace() is None | L113: sys.settrace(self.trace) | L136: sys.settrace(None)"
|
||||
},
|
||||
{
|
||||
"package": "ipython",
|
||||
"file": "IPython/core/debugger.py",
|
||||
|
|
@ -982,20 +982,6 @@
|
|||
"severity": "HIGH",
|
||||
"evidence": "Obfusc: L709: return compile(source, filename, \"exec\")\nExec: L1228: exec(code, namespace)"
|
||||
},
|
||||
{
|
||||
"package": "kgb",
|
||||
"file": "kgb/spies.py",
|
||||
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
|
||||
"severity": "HIGH",
|
||||
"evidence": "Obfusc: L934: eval(compile(func_code_str, '<string>', 'exec'),\nExec: L934: eval(compile(func_code_str, '<string>', 'exec'),"
|
||||
},
|
||||
{
|
||||
"package": "langid",
|
||||
"file": "langid/train/common.py",
|
||||
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
|
||||
"severity": "HIGH",
|
||||
"evidence": "Obfusc: L44: yield marshal.load(t)\nExec: L85: key = eval(row[0])"
|
||||
},
|
||||
{
|
||||
"package": "matplotlib",
|
||||
"file": "matplotlib/sphinxext/plot_directive.py",
|
||||
|
|
@ -1108,20 +1094,6 @@
|
|||
"severity": "HIGH",
|
||||
"evidence": "Obfusc: L45: mod = __import__(module_name, None, None, ['__all__'])\nExec: L154: exec(f.read(), custom_namespace)"
|
||||
},
|
||||
{
|
||||
"package": "pytest",
|
||||
"file": "_pytest/_py/path.py",
|
||||
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
|
||||
"severity": "HIGH",
|
||||
"evidence": "Obfusc: L626: mod = __import__(hashtype) | L1118: __import__(modname)\nExec: L1153: exec(f.read(), mod.__dict__)"
|
||||
},
|
||||
{
|
||||
"package": "pytest",
|
||||
"file": "_pytest/assertion/rewrite.py",
|
||||
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
|
||||
"severity": "HIGH",
|
||||
"evidence": "Obfusc: L393: co = marshal.load(fp) | L395: trace(f\"_read_pyc({source}): marshal.load error {e}\")\nExec: L188: exec(co, module.__dict__)"
|
||||
},
|
||||
{
|
||||
"package": "scikit-learn",
|
||||
"file": "sklearn/externals/array_api_compat/torch/__init__.py",
|
||||
|
|
@ -1318,6 +1290,13 @@
|
|||
"severity": "HIGH",
|
||||
"evidence": "Obfusc: L3078: module = __import__('transformers', fromlist=[model_class_name])\nExec: L2960: exec(f\"from transformers.modeling_utils import ({', '.join(functions)})\", locals(), globals()) | L3006: exec(save_pretrained, globals(), functions)"
|
||||
},
|
||||
{
|
||||
"package": "unsloth-zoo",
|
||||
"file": "tests/test_mlx_trainer_internals.py",
|
||||
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
|
||||
"severity": "HIGH",
|
||||
"evidence": "Obfusc: L430: assert ppl == pytest.approx(__import__(\"math\").exp(2.5))\nExec: L408: def eval(self):"
|
||||
},
|
||||
{
|
||||
"package": "werkzeug",
|
||||
"file": "werkzeug/routing/rules.py",
|
||||
|
|
|
|||
|
|
@ -581,9 +581,16 @@ def compare(before_src: str, after_src: str, path: str) -> list[tuple[str, str]]
|
|||
)
|
||||
|
||||
# 3. TARGET-CHANGED (same scope+name resolves to a different import target)
|
||||
# Only a *swap* is dangerous: a BEFORE target that is no longer reachable in
|
||||
# AFTER means a reference was silently re-pointed. A pure superset growth
|
||||
# (tbefore <= tafter) is the benign `import pkg.subA` + `import pkg.subB`
|
||||
# case: both statements bind the same top-level name `pkg` to the same
|
||||
# package object and only *add* submodule attributes (e.g. adding
|
||||
# `import urllib.error` next to `import urllib.request`). Nothing the name
|
||||
# resolved to before is lost, so no reference is re-pointed -- skip it.
|
||||
for key, tafter in b["target_by_use"].items():
|
||||
tbefore = a["target_by_use"].get(key)
|
||||
if tbefore and tbefore != tafter:
|
||||
if tbefore and tbefore != tafter and (tbefore - tafter):
|
||||
findings.append(
|
||||
(
|
||||
"BLOCKER",
|
||||
|
|
|
|||
398
studio/backend/assets/preview_page.html
Normal file
398
studio/backend/assets/preview_page.html
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>__TITLE__ - Unsloth</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Hellix";
|
||||
src: url("/p/_assets/fonts/Hellix-Medium.woff") format("woff");
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Hellix";
|
||||
src: url("/p/_assets/fonts/Hellix-SemiBold.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
}
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #fefefd;
|
||||
--fg: #0d0d0d;
|
||||
--muted: #858279;
|
||||
--border: #ececec;
|
||||
--user-bubble: #f5f5f5;
|
||||
--primary: #17b88b;
|
||||
--composer-bg: #ffffff;
|
||||
--composer-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #1a1b1e;
|
||||
--fg: #ececee;
|
||||
--muted: #96979b;
|
||||
--border: #3a3d42;
|
||||
--user-bubble: #2d2e32;
|
||||
--composer-bg: #2d2e32;
|
||||
--composer-shadow: none;
|
||||
}
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font:
|
||||
15.5px/1.6 "Inter",
|
||||
"Inter Variable",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
system-ui,
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.heading {
|
||||
font-family: "Hellix", "Space Grotesk", system-ui, sans-serif;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 14px 20px;
|
||||
}
|
||||
header img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.brand {
|
||||
font-family: "Hellix", "Space Grotesk", system-ui, sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.model {
|
||||
margin-left: auto;
|
||||
max-width: 55%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
#log {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 16px 24px;
|
||||
}
|
||||
#thread {
|
||||
width: 100%;
|
||||
max-width: 46.5rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.welcome {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
padding: 0 16px;
|
||||
animation: fade 0.25s ease-out;
|
||||
}
|
||||
.welcome h1 {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.welcome p {
|
||||
margin: 0.55rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.msg {
|
||||
font-size: 15.5px;
|
||||
font-weight: 450;
|
||||
letter-spacing: 0.01em;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
animation: fade 0.15s ease-out;
|
||||
}
|
||||
.user {
|
||||
align-self: flex-end;
|
||||
max-width: 80%;
|
||||
margin-top: 24px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 24px;
|
||||
background: var(--user-bubble);
|
||||
}
|
||||
.assistant {
|
||||
align-self: stretch;
|
||||
margin-top: 16px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.dots {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
height: 1.6em;
|
||||
}
|
||||
.dots i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
animation: blink 1.2s infinite;
|
||||
}
|
||||
.dots i:nth-child(2) {
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
.dots i:nth-child(3) {
|
||||
animation-delay: 0.36s;
|
||||
}
|
||||
.composer-wrap {
|
||||
padding: 6px 16px 16px;
|
||||
}
|
||||
form {
|
||||
width: 100%;
|
||||
max-width: 46.5rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.composer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 8px 8px 8px 18px;
|
||||
border-radius: 28px;
|
||||
background: var(--composer-bg);
|
||||
box-shadow: var(--composer-shadow);
|
||||
}
|
||||
textarea {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
resize: none;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
max-height: 200px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
textarea::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
.send {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.send:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.foot {
|
||||
margin: 9px auto 0;
|
||||
max-width: 46.5rem;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
@keyframes blink {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<img src="/p/_assets/circle-logo-small.png" alt="" /><span class="brand"
|
||||
>Unsloth</span
|
||||
><span class="model">__TITLE__</span>
|
||||
</header>
|
||||
<main id="log">
|
||||
<div id="welcome" class="welcome">
|
||||
<h1 class="heading">Chat with your model</h1>
|
||||
<p>Fine-tuned with Unsloth</p>
|
||||
</div>
|
||||
<div id="thread"></div>
|
||||
</main>
|
||||
<div class="composer-wrap">
|
||||
<form id="f">
|
||||
<div class="composer">
|
||||
<textarea
|
||||
id="i"
|
||||
rows="1"
|
||||
autocomplete="off"
|
||||
placeholder="Message this model..."
|
||||
></textarea>
|
||||
<button id="b" class="send" aria-label="Send">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 19V5" />
|
||||
<path d="M5 12l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="foot">Served by Unsloth Studio</div>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
const base = location.pathname.replace(/\/+$/, "");
|
||||
const log = document.getElementById("log"),
|
||||
thread = document.getElementById("thread"),
|
||||
welcome = document.getElementById("welcome");
|
||||
const form = document.getElementById("f"),
|
||||
input = document.getElementById("i"),
|
||||
btn = document.getElementById("b");
|
||||
const msgs = [];
|
||||
const down = () => {
|
||||
log.scrollTop = log.scrollHeight;
|
||||
};
|
||||
function autosize() {
|
||||
input.style.height = "auto";
|
||||
input.style.height = Math.min(input.scrollHeight, 200) + "px";
|
||||
}
|
||||
input.addEventListener("input", autosize);
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
// send() (not form.requestSubmit, unsupported on Safari < 16) guards the btn.
|
||||
send();
|
||||
}
|
||||
});
|
||||
function add(role) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "msg " + role;
|
||||
thread.appendChild(d);
|
||||
down();
|
||||
return d;
|
||||
}
|
||||
async function send() {
|
||||
// One path for button + Enter; ignore while a request is in flight.
|
||||
if (btn.disabled) return;
|
||||
const content = input.value.trim();
|
||||
if (!content) return;
|
||||
if (welcome) welcome.style.display = "none";
|
||||
input.value = "";
|
||||
autosize();
|
||||
btn.disabled = true;
|
||||
msgs.push({ role: "user", content });
|
||||
add("user").textContent = content;
|
||||
const out = add("assistant");
|
||||
out.innerHTML = '<span class="dots"><i></i><i></i><i></i></span>';
|
||||
let acc = "";
|
||||
try {
|
||||
const r = await fetch(base + "/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "preview",
|
||||
messages: msgs,
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
out.textContent =
|
||||
"Error " + r.status + ": " + (await r.text()).slice(0, 300);
|
||||
msgs.pop();
|
||||
input.value = content; // restore the prompt so the user can retry
|
||||
autosize();
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const reader = r.body.getReader(),
|
||||
dec = new TextDecoder();
|
||||
let buf = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let i;
|
||||
while ((i = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, i).trim();
|
||||
buf = buf.slice(i + 1);
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const data = line.slice(5).trim();
|
||||
if (data === "[DONE]") continue;
|
||||
try {
|
||||
const j = JSON.parse(data);
|
||||
const d =
|
||||
j.choices &&
|
||||
j.choices[0] &&
|
||||
j.choices[0].delta &&
|
||||
j.choices[0].delta.content;
|
||||
if (d) {
|
||||
acc += d;
|
||||
out.textContent = acc;
|
||||
down();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
if (!acc) out.textContent = "";
|
||||
msgs.push({ role: "assistant", content: acc });
|
||||
} catch (err) {
|
||||
// Keep any streamed text, flag the break, restore the prompt for retry.
|
||||
out.textContent = acc ? acc + "\n\n[connection lost]" : "Network error, please retry.";
|
||||
msgs.pop();
|
||||
input.value = content;
|
||||
autosize();
|
||||
}
|
||||
btn.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
send();
|
||||
});
|
||||
autosize();
|
||||
input.focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -26,6 +26,7 @@ from utils.hardware import (
|
|||
)
|
||||
from core.inference.audio_codecs import AudioCodecManager
|
||||
from core.inference.runtime_context import runtime_context_length
|
||||
from core.inference.message_content import content_to_text
|
||||
from io import StringIO
|
||||
import structlog
|
||||
from loggers import get_logger
|
||||
|
|
@ -1018,7 +1019,7 @@ class InferenceBackend:
|
|||
user_message = ""
|
||||
if messages and messages[-1]["role"] == "user":
|
||||
import re
|
||||
user_message = messages[-1]["content"]
|
||||
user_message = content_to_text(messages[-1]["content"])
|
||||
user_message = re.sub(r"<img[^>]*>", "", user_message).strip()
|
||||
|
||||
if not user_message:
|
||||
|
|
@ -1181,7 +1182,7 @@ class InferenceBackend:
|
|||
if messages:
|
||||
for msg in reversed(messages):
|
||||
if msg["role"] == "user" and msg.get("content"):
|
||||
user_text = msg["content"]
|
||||
user_text = content_to_text(msg["content"])
|
||||
break
|
||||
|
||||
# ASR-specific default system prompt if none set
|
||||
|
|
@ -1713,7 +1714,7 @@ class InferenceBackend:
|
|||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
content = content_to_text(msg.get("content", ""))
|
||||
|
||||
if role in ["system", "user", "assistant"] and content.strip():
|
||||
if role == last_role:
|
||||
|
|
@ -1801,7 +1802,7 @@ class InferenceBackend:
|
|||
|
||||
for msg in messages:
|
||||
role = msg["role"]
|
||||
content = msg["content"]
|
||||
content = content_to_text(msg["content"])
|
||||
formatted += f"<|start_header_id|>{role}<|end_header_id|>\n\n{content}<|eot_id|>"
|
||||
|
||||
formatted += "<|start_header_id|>assistant<|end_header_id|>\n\n"
|
||||
|
|
@ -1817,14 +1818,14 @@ class InferenceBackend:
|
|||
|
||||
for msg in messages:
|
||||
if msg["role"] == "system":
|
||||
system_msg = msg["content"]
|
||||
system_msg = content_to_text(msg["content"])
|
||||
else:
|
||||
conversation.append(msg)
|
||||
|
||||
i = 0
|
||||
while i < len(conversation):
|
||||
if conversation[i]["role"] == "user":
|
||||
user_content = conversation[i]["content"]
|
||||
user_content = content_to_text(conversation[i]["content"])
|
||||
|
||||
if system_msg and i == 0:
|
||||
user_content = f"{system_msg}\n\n{user_content}"
|
||||
|
|
@ -1832,7 +1833,7 @@ class InferenceBackend:
|
|||
formatted += f"[INST] {user_content} [/INST]"
|
||||
|
||||
if i + 1 < len(conversation) and conversation[i + 1]["role"] == "assistant":
|
||||
formatted += f" {conversation[i + 1]['content']}</s>"
|
||||
formatted += f" {content_to_text(conversation[i + 1]['content'])}</s>"
|
||||
i += 2
|
||||
else:
|
||||
formatted += " "
|
||||
|
|
@ -1848,7 +1849,7 @@ class InferenceBackend:
|
|||
|
||||
for msg in messages:
|
||||
role = msg["role"]
|
||||
content = msg["content"]
|
||||
content = content_to_text(msg["content"])
|
||||
formatted += f"<|im_start|>{role}\n{content}<|im_end|>\n"
|
||||
|
||||
formatted += "<|im_start|>assistant\n"
|
||||
|
|
@ -1860,16 +1861,17 @@ class InferenceBackend:
|
|||
system_msg = None
|
||||
|
||||
for msg in messages:
|
||||
content = content_to_text(msg["content"])
|
||||
if msg["role"] == "system":
|
||||
system_msg = msg["content"]
|
||||
system_msg = content
|
||||
elif msg["role"] == "user":
|
||||
if system_msg:
|
||||
formatted += f"### Instruction:\n{system_msg}\n\n### Input:\n{msg['content']}\n\n### Response:\n"
|
||||
formatted += f"### Instruction:\n{system_msg}\n\n### Input:\n{content}\n\n### Response:\n"
|
||||
system_msg = None
|
||||
else:
|
||||
formatted += f"### Human:\n{msg['content']}\n\n### Assistant:\n"
|
||||
formatted += f"### Human:\n{content}\n\n### Assistant:\n"
|
||||
elif msg["role"] == "assistant":
|
||||
formatted += f"{msg['content']}\n\n"
|
||||
formatted += f"{content}\n\n"
|
||||
|
||||
return formatted
|
||||
|
||||
|
|
@ -1879,7 +1881,7 @@ class InferenceBackend:
|
|||
|
||||
for msg in messages:
|
||||
role = msg["role"].title()
|
||||
content = msg["content"]
|
||||
content = content_to_text(msg["content"])
|
||||
formatted += f"{role}: {content}\n"
|
||||
|
||||
formatted += "Assistant: "
|
||||
|
|
|
|||
|
|
@ -803,6 +803,10 @@ _MTP_MIN_SIZE_B = 3.0
|
|||
# of free (see _fit_context_to_vram), plus a byte-accurate MTP draft reserve.
|
||||
_CTX_FIT_VRAM_FRACTION = 0.95
|
||||
|
||||
# Apple unified memory is shared with the OS, so tighter than VRAM. Matches the
|
||||
# 0.85 MLX uses in mlx_inference.py (_configure_memory_limits); not kept in sync.
|
||||
_APPLE_UNIFIED_MEMORY_FRACTION = 0.85
|
||||
|
||||
# Flat MTP reserve, used only when GGUF dims are too sparse for the byte-accurate
|
||||
# reserve (_estimate_mtp_overhead_bytes). Applied to both the fit budget and pin.
|
||||
_MTP_VRAM_RESERVE_FRAC = 0.05
|
||||
|
|
@ -2165,6 +2169,34 @@ class LlamaCppBackend:
|
|||
``_get_gpu_memory`` for callers that only need free VRAM."""
|
||||
return [(idx, free) for idx, free, _total in LlamaCppBackend._get_gpu_memory()]
|
||||
|
||||
@staticmethod
|
||||
def _apple_metal_memory_budget_bytes() -> int:
|
||||
"""Unified-memory budget for GGUF context fitting on Apple Silicon.
|
||||
|
||||
No GPU is enumerated on Metal, so the context would default to native and
|
||||
over-commit unified memory ("Compute error." at decode, #5118/#6529). Use a
|
||||
fraction of MLX's Metal working-set, else total RAM; 0 off Apple Silicon or
|
||||
when unresolvable, so callers skip the cap.
|
||||
"""
|
||||
from utils.hardware import is_apple_silicon
|
||||
|
||||
if not is_apple_silicon():
|
||||
return 0
|
||||
rec_bytes = 0
|
||||
try:
|
||||
import mlx.core as mx
|
||||
if mx.metal.is_available():
|
||||
rec_bytes = int(mx.device_info().get("max_recommended_working_set_size") or 0)
|
||||
except Exception:
|
||||
rec_bytes = 0
|
||||
if rec_bytes <= 0:
|
||||
try:
|
||||
import psutil
|
||||
rec_bytes = int(psutil.virtual_memory().total)
|
||||
except Exception:
|
||||
return 0
|
||||
return int(rec_bytes * _APPLE_UNIFIED_MEMORY_FRACTION)
|
||||
|
||||
@staticmethod
|
||||
def _get_gpu_memory() -> list[tuple[int, int, int]]:
|
||||
"""Query free AND total memory per GPU.
|
||||
|
|
@ -5027,6 +5059,8 @@ class LlamaCppBackend:
|
|||
else 0.0
|
||||
)
|
||||
_pin_fraction = self._GPU_PIN_VRAM_FRACTION - _flat_mtp_reserve
|
||||
# Unified-memory budget (0 off Apple Silicon) for the no-GPU Metal cap below.
|
||||
_apple_budget_mib = self._apple_metal_memory_budget_bytes() // (1024 * 1024)
|
||||
|
||||
def _restore_after_tensor_downgrade():
|
||||
# Tensor mode dropped a quantized KV and stripped the cache
|
||||
|
|
@ -5310,6 +5344,52 @@ class LlamaCppBackend:
|
|||
# so the slider isn't on an unusable native ctx.
|
||||
effective_ctx = min(4096, effective_ctx) if effective_ctx > 0 else 4096
|
||||
|
||||
elif _apple_budget_mib > 0 and effective_ctx > 0:
|
||||
# No GPU on Metal: the branches above are skipped and the context
|
||||
# stays at native, over-committing unified memory (#5118, #6529).
|
||||
# Cap with the same fit math (--fit on stays as a backstop); only
|
||||
# auto context shrinks, explicit is honored.
|
||||
native_ctx_for_cap = self._context_length or effective_ctx
|
||||
# Reserve the flat MTP fraction up front like the discrete
|
||||
# _pin_fraction, so an unsized MTP draft (e.g. Qwen3.6-MTP, #6529)
|
||||
# can't over-commit. No-op when MTP is off; exclusive with the
|
||||
# byte-accurate _mtp_bytes reserve.
|
||||
_apple_fit_budget_mib = int(
|
||||
_apple_budget_mib * max(0.0, 1.0 - _flat_mtp_reserve)
|
||||
)
|
||||
if self._can_estimate_kv():
|
||||
cap = self._fit_context_to_vram(
|
||||
native_ctx_for_cap,
|
||||
_apple_fit_budget_mib,
|
||||
model_size_fit,
|
||||
cache_type_kv,
|
||||
n_parallel = n_parallel,
|
||||
mtp_engaged = _mtp_reserves_gpu,
|
||||
mtp_overhead_fn = mtp_overhead_fn,
|
||||
budget_frac = 1.0,
|
||||
total_mib = None,
|
||||
)
|
||||
_cap_footprint_mib = (
|
||||
model_size_fit
|
||||
+ self._estimate_kv_cache_bytes(
|
||||
cap, cache_type_kv, n_parallel = n_parallel
|
||||
)
|
||||
+ _mtp_bytes(cap)
|
||||
) / (1024 * 1024)
|
||||
# Fit returns the request unchanged when it fits OR weights
|
||||
# exceed budget; only the latter over-commits, so floor to 4096.
|
||||
max_available_ctx = (
|
||||
cap
|
||||
if _cap_footprint_mib <= _apple_fit_budget_mib
|
||||
else min(4096, native_ctx_for_cap)
|
||||
)
|
||||
else:
|
||||
# No KV estimate: mirror the discrete file-size-only fallback
|
||||
# and floor to 4096 rather than launch at native and over-commit.
|
||||
max_available_ctx = min(4096, native_ctx_for_cap)
|
||||
if not explicit_ctx:
|
||||
effective_ctx = max_available_ctx
|
||||
|
||||
# MTP reserve at the final context, for the logs below.
|
||||
_mtp_reserve_bytes = _mtp_bytes(effective_ctx) if _mtp_will_engage else 0
|
||||
if _mtp_will_engage:
|
||||
|
|
@ -7757,6 +7837,15 @@ class LlamaCppBackend:
|
|||
_accumulated_completion_tokens = 0
|
||||
_accumulated_predicted_ms = 0.0
|
||||
_accumulated_predicted_n = 0
|
||||
# GGUF buffers reasoning; emit server-side timing before answer text.
|
||||
_reasoning_started_at: Optional[float] = None
|
||||
_reasoning_summary_emitted = False
|
||||
|
||||
def _reasoning_summary_event(started_at: float) -> dict:
|
||||
return {
|
||||
"type": "reasoning_summary",
|
||||
"duration_ms": round((time.monotonic() - started_at) * 1000.0),
|
||||
}
|
||||
|
||||
def _strip_tool_markup(
|
||||
text: str,
|
||||
|
|
@ -7792,13 +7881,18 @@ class LlamaCppBackend:
|
|||
_mt["predicted_per_second"] = _mt["predicted_n"] / (
|
||||
_mt["predicted_ms"] / 1000.0
|
||||
)
|
||||
_usage = {
|
||||
"prompt_tokens": _fp,
|
||||
"completion_tokens": _tc,
|
||||
"total_tokens": _fp + _tc,
|
||||
}
|
||||
# Preserve KV-cache hit details (cached_tokens) so the tool path
|
||||
# reports them like the standard non-tool path does, not always 0.
|
||||
if _fu.get("prompt_tokens_details"):
|
||||
_usage["prompt_tokens_details"] = _fu["prompt_tokens_details"]
|
||||
return {
|
||||
"type": "metadata",
|
||||
"usage": {
|
||||
"prompt_tokens": _fp,
|
||||
"completion_tokens": _tc,
|
||||
"total_tokens": _fp + _tc,
|
||||
},
|
||||
"usage": _usage,
|
||||
"timings": _mt,
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
|
|
@ -7894,6 +7988,9 @@ class LlamaCppBackend:
|
|||
content_buffer = "" # Raw content held during BUFFERING
|
||||
content_accum = "" # All content tokens (for tool parsing)
|
||||
reasoning_accum = ""
|
||||
# Time each reasoning pass so final answers can replace tool timing.
|
||||
_reasoning_started_at = None
|
||||
_reasoning_summary_emitted = False
|
||||
cumulative_display = "" # Cumulative yielded text (with <think>)
|
||||
in_thinking = False
|
||||
has_content_tokens = False
|
||||
|
|
@ -8068,6 +8165,8 @@ class LlamaCppBackend:
|
|||
# between tool iterations).
|
||||
reasoning = delta.get("reasoning_content", "")
|
||||
if reasoning:
|
||||
if _reasoning_started_at is None:
|
||||
_reasoning_started_at = time.monotonic()
|
||||
reasoning_accum += reasoning
|
||||
if detect_state == _S_STREAMING:
|
||||
if not in_thinking:
|
||||
|
|
@ -8083,6 +8182,13 @@ class LlamaCppBackend:
|
|||
# ── Content tokens ──
|
||||
token = delta.get("content", "")
|
||||
if token:
|
||||
# First answer token ends reasoning.
|
||||
if (
|
||||
_reasoning_started_at is not None
|
||||
and not _reasoning_summary_emitted
|
||||
):
|
||||
_reasoning_summary_emitted = True
|
||||
yield _reasoning_summary_event(_reasoning_started_at)
|
||||
has_content_tokens = True
|
||||
content_accum += token
|
||||
|
||||
|
|
@ -8180,9 +8286,10 @@ class LlamaCppBackend:
|
|||
),
|
||||
}
|
||||
elif reasoning_accum and not has_content_tokens:
|
||||
# Reasoning-only response: show reasoning as plain
|
||||
# text, matching the final streaming pass for
|
||||
# models that put everything in reasoning.
|
||||
# Reasoning-only reply: show it as plain text.
|
||||
if _reasoning_started_at is not None and not _reasoning_summary_emitted:
|
||||
_reasoning_summary_emitted = True
|
||||
yield _reasoning_summary_event(_reasoning_started_at)
|
||||
cumulative_display = reasoning_accum
|
||||
if not _suppress_visible_output:
|
||||
yield {
|
||||
|
|
@ -8591,6 +8698,8 @@ class LlamaCppBackend:
|
|||
in_thinking = False
|
||||
has_content_tokens = False
|
||||
reasoning_text = ""
|
||||
_final_reasoning_started_at: Optional[float] = None
|
||||
_final_reasoning_summary_emitted = False
|
||||
_metadata_usage = None
|
||||
_metadata_timings = None
|
||||
_metadata_finish_reason = None
|
||||
|
|
@ -8616,6 +8725,12 @@ class LlamaCppBackend:
|
|||
continue
|
||||
if line == "data: [DONE]":
|
||||
if in_thinking:
|
||||
if (
|
||||
_final_reasoning_started_at is not None
|
||||
and not _final_reasoning_summary_emitted
|
||||
):
|
||||
_final_reasoning_summary_emitted = True
|
||||
yield _reasoning_summary_event(_final_reasoning_started_at)
|
||||
if has_content_tokens:
|
||||
cumulative += "</think>"
|
||||
yield {
|
||||
|
|
@ -8648,6 +8763,8 @@ class LlamaCppBackend:
|
|||
|
||||
reasoning = delta.get("reasoning_content", "")
|
||||
if reasoning:
|
||||
if _final_reasoning_started_at is None:
|
||||
_final_reasoning_started_at = time.monotonic()
|
||||
reasoning_text += reasoning
|
||||
if not in_thinking:
|
||||
cumulative += "<think>"
|
||||
|
|
@ -8657,6 +8774,12 @@ class LlamaCppBackend:
|
|||
|
||||
token = delta.get("content", "")
|
||||
if token:
|
||||
if (
|
||||
_final_reasoning_started_at is not None
|
||||
and not _final_reasoning_summary_emitted
|
||||
):
|
||||
_final_reasoning_summary_emitted = True
|
||||
yield _reasoning_summary_event(_final_reasoning_started_at)
|
||||
has_content_tokens = True
|
||||
if in_thinking:
|
||||
cumulative += "</think>"
|
||||
|
|
|
|||
38
studio/backend/core/inference/message_content.py
Normal file
38
studio/backend/core/inference/message_content.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Normalize chat-message `content` (string or OpenAI multimodal list) to text.
|
||||
|
||||
String-only formatting paths called string ops directly on `content` and broke
|
||||
on the list form (#4383). `content_to_text` collapses either shape to a string,
|
||||
dropping non-text parts. No heavy imports, so it is unit-testable alone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def content_to_text(content: Any) -> str:
|
||||
"""Plain text of a `content`: str unchanged, list/tuple text parts newline-joined
|
||||
(non-text dropped), None to "", else str(content)."""
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, (list, tuple)):
|
||||
parts = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
if item:
|
||||
parts.append(item)
|
||||
elif isinstance(item, dict):
|
||||
# Skip non-text parts (image_url, input_audio, ...).
|
||||
part_type = item.get("type")
|
||||
if part_type is not None and part_type != "text":
|
||||
continue
|
||||
text = item.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
parts.append(text)
|
||||
return "\n".join(parts)
|
||||
return str(content)
|
||||
|
|
@ -7,25 +7,31 @@ Tolerates missing closing tags in either ``<tool_call>{json}</tool_call>``
|
|||
or ``<function=name><parameter=k>v...`` shape.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from core import tool_healing as _tool_healing
|
||||
|
||||
|
||||
# _TOOL_CLOSED_PATS: closed pairs only. _TOOL_ALL_PATS: also trailing unclosed
|
||||
# runs so truncated tails don't leak markup. The [\w-] name set matches OpenAI's
|
||||
# so hyphenated MCP tool names (mcp__srv__list-issues) parse like built-ins.
|
||||
_TOOL_CLOSED_PATS = [
|
||||
re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL),
|
||||
re.compile(r"<function=[\w-]+>.*?</function>", re.DOTALL),
|
||||
]
|
||||
_TOOL_ALL_PATS = _TOOL_CLOSED_PATS + [
|
||||
re.compile(r"<tool_call>.*$", re.DOTALL),
|
||||
re.compile(r"<function=[\w-]+>.*$", re.DOTALL),
|
||||
]
|
||||
_TOOL_ALL_PATS = _tool_healing._TOOL_ALL_PATS
|
||||
|
||||
|
||||
def parse_tool_calls_from_text(
|
||||
content: str,
|
||||
*,
|
||||
id_offset: int = 0,
|
||||
allow_incomplete: bool = True,
|
||||
) -> list[dict]:
|
||||
return _tool_healing.parse_tool_calls_from_text(
|
||||
content,
|
||||
id_offset = id_offset,
|
||||
allow_incomplete = allow_incomplete,
|
||||
)
|
||||
|
||||
|
||||
def strip_tool_markup(text: str, *, final: bool = False) -> str:
|
||||
return _tool_healing.strip_tool_call_markup(text, final = final)
|
||||
|
||||
|
||||
# Prefixes the streaming buffer watches for to gate in-progress text.
|
||||
TOOL_XML_SIGNALS = ("<tool_call>", "<function=")
|
||||
TOOL_XML_SIGNALS = ("<tool_call>", "<|tool_call>", "<function=")
|
||||
|
||||
|
||||
# Nudges + error prefixes shared by the GGUF and safetensors loops.
|
||||
|
|
@ -74,199 +80,6 @@ RAG_SEARCH_CAP_NUDGE = (
|
|||
)
|
||||
|
||||
|
||||
# Pre-compiled patterns reused by ``parse_tool_calls_from_text``.
|
||||
_TC_JSON_START_RE = re.compile(r"<tool_call>\s*\{")
|
||||
_TC_FUNC_START_RE = re.compile(r"<function=([\w-]+)>\s*")
|
||||
_TC_END_TAG_RE = re.compile(r"</tool_call>")
|
||||
_TC_FUNC_CLOSE_RE = re.compile(r"\s*</function>\s*$")
|
||||
# [\w-] so hyphenated MCP param names (issue-number) aren't dropped.
|
||||
_TC_PARAM_START_RE = re.compile(r"<parameter=([\w-]+)>\s*")
|
||||
_TC_PARAM_CLOSE_RE = re.compile(r"\s*</parameter>\s*$")
|
||||
_PARAM_CLOSE_TAG = "</parameter>"
|
||||
_FUNC_CLOSE_TAG = "</function>"
|
||||
|
||||
|
||||
def _inside_open_parameter(content: str, pos: int) -> bool:
|
||||
"""Return True when ``pos`` falls inside an unclosed parameter value."""
|
||||
last_param_start = -1
|
||||
for match in _TC_PARAM_START_RE.finditer(content, 0, pos):
|
||||
last_param_start = match.start()
|
||||
if last_param_start < 0:
|
||||
return False
|
||||
last_param_close = content.rfind(_PARAM_CLOSE_TAG, 0, pos)
|
||||
last_func_close = content.rfind(_FUNC_CLOSE_TAG, 0, pos)
|
||||
return last_param_start > max(last_param_close, last_func_close)
|
||||
|
||||
|
||||
def strip_tool_markup(text: str, *, final: bool = False) -> str:
|
||||
"""Strip tool-call XML from streamed text.
|
||||
|
||||
``final=False`` only removes closed pairs (used during streaming so
|
||||
in-progress XML stays buffered). ``final=True`` also removes a
|
||||
trailing unclosed run and trims the result.
|
||||
"""
|
||||
pats = _TOOL_ALL_PATS if final else _TOOL_CLOSED_PATS
|
||||
for pat in pats:
|
||||
text = pat.sub("", text)
|
||||
return text.strip() if final else text
|
||||
|
||||
|
||||
def parse_tool_calls_from_text(
|
||||
content: str,
|
||||
*,
|
||||
id_offset: int = 0,
|
||||
allow_incomplete: bool = True,
|
||||
) -> list[dict]:
|
||||
"""Parse OpenAI-format ``tool_calls`` from model text.
|
||||
|
||||
Returns a list of ``{"id", "type", "function": {"name", "arguments"}}``
|
||||
dicts. ``arguments`` is always a JSON string so callers can hand it
|
||||
straight back into an OpenAI-style response.
|
||||
|
||||
Handles two shapes:
|
||||
|
||||
- JSON inside ``<tool_call>`` tags:
|
||||
``<tool_call>{"name":"web_search","arguments":{"query":"..."}}</tool_call>``
|
||||
- XML-style function blocks:
|
||||
``<function=name><parameter=k>v</parameter></function>``
|
||||
|
||||
``allow_incomplete=True`` keeps the historical healing behavior for
|
||||
missing closing tags. ``allow_incomplete=False`` accepts only
|
||||
well-formed wrappers so disabled Auto-Heal can still parse valid
|
||||
local tool protocol without repairing truncated output.
|
||||
"""
|
||||
tool_calls: list[dict] = []
|
||||
|
||||
# Pattern 1: <tool_call>{json}. Balanced-brace scan, skipping braces in
|
||||
# JSON strings.
|
||||
for m in _TC_JSON_START_RE.finditer(content):
|
||||
brace_start = m.end() - 1 # opening {
|
||||
depth, i = 0, brace_start
|
||||
in_string = False
|
||||
while i < len(content):
|
||||
ch = content[i]
|
||||
if in_string:
|
||||
if ch == "\\" and i + 1 < len(content):
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = False
|
||||
elif ch == '"':
|
||||
in_string = True
|
||||
elif ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
i += 1
|
||||
if depth != 0:
|
||||
continue
|
||||
if not allow_incomplete:
|
||||
tail_after_json = content[i + 1 :].lstrip()
|
||||
if _TC_END_TAG_RE.match(tail_after_json) is None:
|
||||
continue
|
||||
json_str = content[brace_start : i + 1]
|
||||
try:
|
||||
obj = json.loads(json_str)
|
||||
tc = {
|
||||
"id": f"call_{id_offset + len(tool_calls)}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": obj.get("name", ""),
|
||||
"arguments": obj.get("arguments", {}),
|
||||
},
|
||||
}
|
||||
if isinstance(tc["function"]["arguments"], dict):
|
||||
tc["function"]["arguments"] = json.dumps(tc["function"]["arguments"])
|
||||
tool_calls.append(tc)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# Pattern 2: <function=name><parameter=k>v... -- closing tags optional;
|
||||
# </function> isn't a body boundary since code values can contain it.
|
||||
if not tool_calls:
|
||||
func_starts = [
|
||||
fm
|
||||
for fm in _TC_FUNC_START_RE.finditer(content)
|
||||
if not _inside_open_parameter(content, fm.start())
|
||||
]
|
||||
for idx, fm in enumerate(func_starts):
|
||||
func_name = fm.group(1)
|
||||
body_start = fm.end()
|
||||
next_func = func_starts[idx + 1].start() if idx + 1 < len(func_starts) else len(content)
|
||||
end_tag = _TC_END_TAG_RE.search(content[body_start:])
|
||||
if end_tag:
|
||||
body_end = body_start + end_tag.start()
|
||||
else:
|
||||
body_end = len(content)
|
||||
body_end = min(body_end, next_func)
|
||||
body = content[body_start:body_end]
|
||||
if not allow_incomplete:
|
||||
# Bound the body at the closing </function> tag rather than
|
||||
# the end of the response, so a complete call followed by
|
||||
# trailing prose is still accepted (matching the JSON-style
|
||||
# <tool_call> path, which already tolerates trailing text).
|
||||
# rfind picks the last </function>, so a literal </function>
|
||||
# inside a code parameter value stays in the body.
|
||||
close_idx = body.rfind(_FUNC_CLOSE_TAG)
|
||||
if close_idx < 0:
|
||||
continue
|
||||
body = body[:close_idx]
|
||||
else:
|
||||
body = _TC_FUNC_CLOSE_RE.sub("", body)
|
||||
|
||||
arguments: dict = {}
|
||||
param_starts = list(_TC_PARAM_START_RE.finditer(body))
|
||||
if len(param_starts) == 1:
|
||||
# Single param: take everything to body end so an embedded
|
||||
# </parameter> in code strings is preserved.
|
||||
pm = param_starts[0]
|
||||
val = body[pm.end() :]
|
||||
if not allow_incomplete:
|
||||
stripped_val = val.rstrip()
|
||||
if not stripped_val.endswith(_PARAM_CLOSE_TAG):
|
||||
continue
|
||||
val = stripped_val[: -len(_PARAM_CLOSE_TAG)]
|
||||
else:
|
||||
val = _TC_PARAM_CLOSE_RE.sub("", val)
|
||||
arguments[pm.group(1)] = val.strip()
|
||||
else:
|
||||
valid_params = True
|
||||
for pidx, pm in enumerate(param_starts):
|
||||
param_name = pm.group(1)
|
||||
val_start = pm.end()
|
||||
next_param = (
|
||||
param_starts[pidx + 1].start()
|
||||
if pidx + 1 < len(param_starts)
|
||||
else len(body)
|
||||
)
|
||||
val = body[val_start:next_param]
|
||||
if not allow_incomplete:
|
||||
stripped_val = val.rstrip()
|
||||
if not stripped_val.endswith(_PARAM_CLOSE_TAG):
|
||||
valid_params = False
|
||||
break
|
||||
val = stripped_val[: -len(_PARAM_CLOSE_TAG)]
|
||||
else:
|
||||
val = _TC_PARAM_CLOSE_RE.sub("", val)
|
||||
arguments[param_name] = val.strip()
|
||||
if not valid_params:
|
||||
continue
|
||||
|
||||
tc = {
|
||||
"id": f"call_{id_offset + len(tool_calls)}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": func_name,
|
||||
"arguments": json.dumps(arguments),
|
||||
},
|
||||
}
|
||||
tool_calls.append(tc)
|
||||
|
||||
return tool_calls
|
||||
|
||||
|
||||
def has_tool_signal(text: str) -> bool:
|
||||
"""Return True if ``text`` contains any tool-call XML signal."""
|
||||
return any(s in text for s in TOOL_XML_SIGNALS)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,23 @@ def _device() -> str:
|
|||
return _TORCH_DEVICE.get(get_device(), "cpu")
|
||||
|
||||
|
||||
_torchao_stub_done = False
|
||||
|
||||
|
||||
def _install_torchao_stub_once() -> None:
|
||||
"""Neutralize torchao before importing sentence-transformers. On Windows ROCm,
|
||||
torchao (pulled in by transformers.quantizers) imports an absent c10d backend
|
||||
and aborts, dropping the embedder to llama-server. Workers stub it too; the
|
||||
embedder runs in the main process. No-op elsewhere; runs once under ``_lock``."""
|
||||
global _torchao_stub_done
|
||||
if _torchao_stub_done:
|
||||
return
|
||||
_torchao_stub_done = True
|
||||
from core._torchao_stub import install_torchao_windows_rocm_stub
|
||||
|
||||
install_torchao_windows_rocm_stub()
|
||||
|
||||
|
||||
def _get(model_name: str | None = None):
|
||||
"""Cached SentenceTransformer, (re)loading on a name change. Loaded in fp16
|
||||
for a ~1.5x speedup at negligible accuracy loss."""
|
||||
|
|
@ -53,6 +70,7 @@ def _get(model_name: str | None = None):
|
|||
name = model_name or config.EMBEDDING_MODEL
|
||||
with _lock:
|
||||
if _model is None or _name != name:
|
||||
_install_torchao_stub_once()
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
device = _device()
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Tool-call XML parsing and stripping helpers.
|
||||
"""Lightweight tool-call XML parsing and stripping helpers.
|
||||
|
||||
Extracted verbatim from studio/backend/core/inference/llama_cpp.py so external
|
||||
inference servers can reuse the logic without importing the inference
|
||||
External inference servers import this module without pulling in the inference
|
||||
orchestrator, structlog, httpx, or the rest of the studio backend.
|
||||
|
||||
Regexes and bodies are byte-for-byte identical to the original; any change must
|
||||
preserve that. test_tool_healing_extraction_is_exact.py verifies via AST.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
|
@ -19,86 +15,370 @@ import re
|
|||
# issue-number) parse alongside the built-ins.
|
||||
_TOOL_CLOSED_PATS = [
|
||||
re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL),
|
||||
re.compile(r"<\|tool_call>.*?<tool_call\|>", re.DOTALL),
|
||||
re.compile(r"<tool_call\|>"),
|
||||
re.compile(r"<function=[\w-]+>.*?</function>", re.DOTALL),
|
||||
]
|
||||
_TOOL_ALL_PATS = _TOOL_CLOSED_PATS + [
|
||||
re.compile(r"<tool_call>.*$", re.DOTALL),
|
||||
re.compile(r"<\|tool_call>.*$", re.DOTALL),
|
||||
re.compile(r"<function=[\w-]+>.*$", re.DOTALL),
|
||||
]
|
||||
|
||||
# Pre-compiled patterns for tool-call XML parsing.
|
||||
_TC_JSON_START_RE = re.compile(r"<tool_call>\s*\{")
|
||||
_TC_GEMMA_START_RE = re.compile(r"<\|tool_call>call:([\w-]+)\s*\{")
|
||||
_TC_FUNC_START_RE = re.compile(r"<function=([\w-]+)>\s*")
|
||||
_TC_END_TAG_RE = re.compile(r"</tool_call>")
|
||||
_TC_GEMMA_END_TAG_RE = re.compile(r"<tool_call\|>")
|
||||
_TC_FUNC_CLOSE_RE = re.compile(r"\s*</function>\s*$")
|
||||
_TC_PARAM_START_RE = re.compile(r"<parameter=([\w-]+)>\s*")
|
||||
_TC_PARAM_CLOSE_RE = re.compile(r"\s*</parameter>\s*$")
|
||||
_GEMMA_QUOTE = '<|"|>'
|
||||
_PARAM_CLOSE_TAG = "</parameter>"
|
||||
_FUNC_CLOSE_TAG = "</function>"
|
||||
# A bare (unquoted) Gemma value ends at `}` or at a comma that begins the next
|
||||
# `key:` pair. A comma NOT followed by a key token is part of the value (e.g.
|
||||
# `location:New York, NY`), so it must not terminate the value. The key token
|
||||
# must be identifier-shaped (start with a letter or underscore); a comma
|
||||
# followed by digits-then-colon is value text such as a timestamp or ratio
|
||||
# (`meet at 10:00, 11:00 tomorrow`), not a new key.
|
||||
_GEMMA_NEXT_KEY_RE = re.compile(r"\s*[A-Za-z_][\w-]*\s*:")
|
||||
|
||||
|
||||
def parse_tool_calls_from_text(content: str) -> list[dict]:
|
||||
"""
|
||||
Parse tool calls from XML markup in content text.
|
||||
def _balanced_brace_end(
|
||||
content: str,
|
||||
brace_start: int,
|
||||
*,
|
||||
gemma_quotes: bool = False,
|
||||
) -> int:
|
||||
depth = 0
|
||||
i = brace_start
|
||||
in_string = False
|
||||
in_gemma_string = False
|
||||
while i < len(content):
|
||||
if gemma_quotes and not in_string and content.startswith(_GEMMA_QUOTE, i):
|
||||
in_gemma_string = not in_gemma_string
|
||||
i += len(_GEMMA_QUOTE)
|
||||
continue
|
||||
ch = content[i]
|
||||
if in_gemma_string:
|
||||
i += 1
|
||||
continue
|
||||
if in_string:
|
||||
if ch == "\\" and i + 1 < len(content):
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = False
|
||||
elif ch == '"':
|
||||
in_string = True
|
||||
elif ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
i += 1
|
||||
return -1
|
||||
|
||||
|
||||
def _balanced_bracket_end(src: str, start: int) -> int:
|
||||
"""Index of the ``]`` matching the ``[`` at ``start``, or -1. Tracks nested
|
||||
``[]``/``{}`` and double-quoted strings."""
|
||||
depth = 0
|
||||
i = start
|
||||
in_string = False
|
||||
while i < len(src):
|
||||
ch = src[i]
|
||||
if in_string:
|
||||
if ch == "\\" and i + 1 < len(src):
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = False
|
||||
elif ch == '"':
|
||||
in_string = True
|
||||
elif ch in "[{":
|
||||
depth += 1
|
||||
elif ch in "]}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
i += 1
|
||||
return -1
|
||||
|
||||
|
||||
def _split_top_level_commas(src: str) -> list:
|
||||
"""Split on commas that are not inside a nested ``[]``/``{}`` or a string."""
|
||||
parts: list[str] = []
|
||||
depth = 0
|
||||
in_string = False
|
||||
start = 0
|
||||
i = 0
|
||||
while i < len(src):
|
||||
ch = src[i]
|
||||
if in_string:
|
||||
if ch == "\\" and i + 1 < len(src):
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = False
|
||||
elif ch == '"':
|
||||
in_string = True
|
||||
elif ch in "[{":
|
||||
depth += 1
|
||||
elif ch in "]}":
|
||||
depth -= 1
|
||||
elif ch == "," and depth == 0:
|
||||
parts.append(src[start:i])
|
||||
start = i + 1
|
||||
i += 1
|
||||
parts.append(src[start:])
|
||||
return parts
|
||||
|
||||
|
||||
def _quote_gemma_array_elements(body: str) -> str:
|
||||
"""Normalise the elements of a Gemma array value so json.loads succeeds.
|
||||
|
||||
Gemma may emit ``labels:[bug,ui]`` without per-element quotes, or arrays of
|
||||
objects (``items:[{path:a}]``) whose keys/values also lack quotes; left
|
||||
as-is json.loads fails and the whole call is dropped. Bare string elements
|
||||
are quoted, object and nested-array elements are normalised recursively, and
|
||||
quoted strings (already normalised from ``<|"|>``), numbers, and JSON
|
||||
literals are preserved."""
|
||||
out: list[str] = []
|
||||
for element in _split_top_level_commas(body):
|
||||
stripped = element.strip()
|
||||
if not stripped or stripped[0] == '"':
|
||||
out.append(element)
|
||||
continue
|
||||
if stripped[0] == "{":
|
||||
# Object element: quote its keys/bare values like a top-level object.
|
||||
out.append(_quote_gemma_object_keys(stripped))
|
||||
continue
|
||||
if stripped[0] == "[":
|
||||
# Nested array: normalise its elements too.
|
||||
inner_end = _balanced_bracket_end(stripped, 0)
|
||||
if inner_end == len(stripped) - 1:
|
||||
out.append("[" + _quote_gemma_array_elements(stripped[1:inner_end]) + "]")
|
||||
else:
|
||||
out.append(element)
|
||||
continue
|
||||
try:
|
||||
json.loads(stripped)
|
||||
out.append(element)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
out.append(json.dumps(stripped))
|
||||
return ",".join(out)
|
||||
|
||||
|
||||
def _normalise_gemma_quoted_strings(src: str) -> str:
|
||||
parts: list[str] = []
|
||||
i = 0
|
||||
while i < len(src):
|
||||
if not src.startswith(_GEMMA_QUOTE, i):
|
||||
parts.append(src[i])
|
||||
i += 1
|
||||
continue
|
||||
end = src.find(_GEMMA_QUOTE, i + len(_GEMMA_QUOTE))
|
||||
if end < 0:
|
||||
parts.append(src[i:])
|
||||
break
|
||||
raw_value = src[i + len(_GEMMA_QUOTE) : end]
|
||||
parts.append(json.dumps(raw_value))
|
||||
i = end + len(_GEMMA_QUOTE)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _quote_gemma_object_keys(src: str) -> str:
|
||||
parts: list[str] = []
|
||||
i = 0
|
||||
in_string = False
|
||||
while i < len(src):
|
||||
ch = src[i]
|
||||
if in_string:
|
||||
parts.append(ch)
|
||||
if ch == "\\" and i + 1 < len(src):
|
||||
parts.append(src[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = False
|
||||
i += 1
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
parts.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch not in "{,":
|
||||
parts.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
parts.append(ch)
|
||||
i += 1
|
||||
key_start = i
|
||||
while i < len(src) and src[i].isspace():
|
||||
i += 1
|
||||
key_name_start = i
|
||||
while i < len(src) and (src[i].isalnum() or src[i] in "_-"):
|
||||
i += 1
|
||||
key_name = src[key_name_start:i]
|
||||
colon_pos = i
|
||||
while colon_pos < len(src) and src[colon_pos].isspace():
|
||||
colon_pos += 1
|
||||
if key_name and colon_pos < len(src) and src[colon_pos] == ":":
|
||||
parts.append(src[key_start:key_name_start])
|
||||
parts.append(json.dumps(key_name))
|
||||
parts.append(src[i:colon_pos])
|
||||
parts.append(":")
|
||||
i = colon_pos + 1
|
||||
# Gemma may emit bare string values ({unit:celsius}); quote them so
|
||||
# json.loads succeeds. JSON scalars/objects/arrays/quoted stay as-is.
|
||||
ws = i
|
||||
while i < len(src) and src[i].isspace():
|
||||
i += 1
|
||||
parts.append(src[ws:i])
|
||||
if i < len(src) and src[i] == "[":
|
||||
# Array value: quote bare string elements (e.g. labels:[bug,ui])
|
||||
# so json.loads succeeds instead of dropping the call.
|
||||
arr_end = _balanced_bracket_end(src, i)
|
||||
if arr_end < 0:
|
||||
parts.append(src[i:])
|
||||
i = len(src)
|
||||
else:
|
||||
parts.append("[" + _quote_gemma_array_elements(src[i + 1 : arr_end]) + "]")
|
||||
i = arr_end + 1
|
||||
elif i < len(src) and src[i] not in '"{':
|
||||
v_start = i
|
||||
# Consume the bare value up to `}` or a comma that starts the
|
||||
# next key:value pair; a comma inside the value (e.g.
|
||||
# `New York, NY`) does not terminate it.
|
||||
while i < len(src):
|
||||
if src[i] == "}":
|
||||
break
|
||||
if src[i] == "," and _GEMMA_NEXT_KEY_RE.match(src, i + 1):
|
||||
break
|
||||
i += 1
|
||||
raw = src[v_start:i]
|
||||
try:
|
||||
json.loads(raw.strip())
|
||||
parts.append(raw)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parts.append(json.dumps(raw.strip()) if raw.strip() else raw)
|
||||
else:
|
||||
parts.append(src[key_start:i])
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _gemma_arguments_to_json(args_src: str) -> dict:
|
||||
"""Parse Gemma 4's native call:name{key:value} argument object."""
|
||||
args_src = args_src.strip()
|
||||
if not args_src:
|
||||
return {}
|
||||
src = _normalise_gemma_quoted_strings(args_src)
|
||||
src = "{" + src + "}"
|
||||
src = _quote_gemma_object_keys(src)
|
||||
return json.loads(src)
|
||||
|
||||
|
||||
def _inside_open_parameter(content: str, pos: int) -> bool:
|
||||
"""Return True when ``pos`` falls inside an unclosed parameter value."""
|
||||
last_param_start = -1
|
||||
for match in _TC_PARAM_START_RE.finditer(content, 0, pos):
|
||||
last_param_start = match.start()
|
||||
if last_param_start < 0:
|
||||
return False
|
||||
last_param_close = content.rfind(_PARAM_CLOSE_TAG, 0, pos)
|
||||
last_func_close = content.rfind(_FUNC_CLOSE_TAG, 0, pos)
|
||||
return last_param_start > max(last_param_close, last_func_close)
|
||||
|
||||
|
||||
def parse_tool_calls_from_text(
|
||||
content: str,
|
||||
*,
|
||||
id_offset: int = 0,
|
||||
allow_incomplete: bool = True,
|
||||
) -> list[dict]:
|
||||
"""Parse OpenAI-format tool calls from model text.
|
||||
|
||||
Handles formats like:
|
||||
<tool_call>{"name":"web_search","arguments":{"query":"..."}}</tool_call>
|
||||
<|tool_call>call:web_search{query:"..."}<tool_call|>
|
||||
<tool_call><function=web_search><parameter=query>...</parameter></function></tool_call>
|
||||
Closing tags (</tool_call>, </function>, </parameter>) are all
|
||||
optional since models frequently omit them.
|
||||
"""
|
||||
tool_calls = []
|
||||
|
||||
# Pattern 1: JSON inside <tool_call> tags. Balanced-brace extraction that
|
||||
# skips braces inside JSON strings.
|
||||
tool_calls: list[dict] = []
|
||||
# Collect JSON- and Gemma-format candidates with their byte spans, then
|
||||
# accept them in document order. Both order and spans matter:
|
||||
# * tools execute in returned order, so a call appearing earlier in the
|
||||
# text must be emitted first even across the two formats;
|
||||
# * a tool-call marker INSIDE another call's argument string is data, not a
|
||||
# call, so a candidate starting within an already accepted span is
|
||||
# skipped (covers a JSON marker nested in a Gemma arg and a Gemma marker
|
||||
# nested in a JSON arg alike, regardless of which format is outer).
|
||||
candidates = [] # (start, brace_end, kind, match)
|
||||
for m in _TC_JSON_START_RE.finditer(content):
|
||||
brace_start = m.end() - 1 # position of the opening {
|
||||
depth, i = 0, brace_start
|
||||
in_string = False
|
||||
while i < len(content):
|
||||
ch = content[i]
|
||||
if in_string:
|
||||
if ch == "\\" and i + 1 < len(content):
|
||||
i += 2 # skip escaped character
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = False
|
||||
elif ch == '"':
|
||||
in_string = True
|
||||
elif ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
i += 1
|
||||
if depth == 0:
|
||||
json_str = content[brace_start : i + 1]
|
||||
try:
|
||||
obj = json.loads(json_str)
|
||||
tc = {
|
||||
"id": f"call_{len(tool_calls)}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": obj.get("name", ""),
|
||||
"arguments": obj.get("arguments", {}),
|
||||
},
|
||||
}
|
||||
if isinstance(tc["function"]["arguments"], dict):
|
||||
tc["function"]["arguments"] = json.dumps(tc["function"]["arguments"])
|
||||
tool_calls.append(tc)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
# A marker that begins inside an open <function=...><parameter=...> value
|
||||
# is that parameter's data, not its own call; skip it (same guard the
|
||||
# XML-style parser below applies to nested <function= markers).
|
||||
if _inside_open_parameter(content, m.start()):
|
||||
continue
|
||||
end = _balanced_brace_end(content, m.end() - 1)
|
||||
if end >= 0:
|
||||
candidates.append((m.start(), end, "json", m))
|
||||
for m in _TC_GEMMA_START_RE.finditer(content):
|
||||
if _inside_open_parameter(content, m.start()):
|
||||
continue
|
||||
end = _balanced_brace_end(content, m.end() - 1, gemma_quotes = True)
|
||||
if end >= 0:
|
||||
candidates.append((m.start(), end, "gemma", m))
|
||||
candidates.sort(key = lambda c: c[0])
|
||||
|
||||
spans = [(s, e) for s, e, _kind, _m in candidates]
|
||||
for idx, (start, end, kind, m) in enumerate(candidates):
|
||||
# Skip a candidate nested inside another candidate's brace span: it is
|
||||
# the enclosing call's argument data, not its own call. Checked against
|
||||
# every candidate span (not only the ones that parsed successfully), so a
|
||||
# marker inside an outer call that later fails to normalize is still
|
||||
# never promoted to its own executable tool call.
|
||||
if any(s <= start and end <= e for j, (s, e) in enumerate(spans) if j != idx):
|
||||
continue
|
||||
if not allow_incomplete:
|
||||
tail = content[end + 1 :].lstrip()
|
||||
close_re = _TC_END_TAG_RE if kind == "json" else _TC_GEMMA_END_TAG_RE
|
||||
if close_re.match(tail) is None:
|
||||
continue
|
||||
try:
|
||||
if kind == "json":
|
||||
obj = json.loads(content[m.end() - 1 : end + 1])
|
||||
name = obj.get("name", "")
|
||||
arguments = obj.get("arguments", {})
|
||||
if isinstance(arguments, dict):
|
||||
arguments = json.dumps(arguments)
|
||||
else:
|
||||
name = m.group(1)
|
||||
arguments = json.dumps(_gemma_arguments_to_json(content[m.end() : end]))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": f"call_{id_offset + len(tool_calls)}",
|
||||
"type": "function",
|
||||
"function": {"name": name, "arguments": arguments},
|
||||
}
|
||||
)
|
||||
|
||||
# Pattern 2: XML-style <function=name><parameter=key>value</parameter></function>
|
||||
# All closing tags optional; models frequently omit them.
|
||||
if not tool_calls:
|
||||
# Step 1: Find <function=name> positions and extract bodies. Use only
|
||||
# </tool_call> or the next <function= as hard boundaries (</function>
|
||||
# can appear in code values); trim a trailing </function> afterwards.
|
||||
func_starts = list(_TC_FUNC_START_RE.finditer(content))
|
||||
func_starts = [
|
||||
fm
|
||||
for fm in _TC_FUNC_START_RE.finditer(content)
|
||||
if not _inside_open_parameter(content, fm.start())
|
||||
]
|
||||
for idx, fm in enumerate(func_starts):
|
||||
func_name = fm.group(1)
|
||||
body_start = fm.end()
|
||||
# Boundaries: next <function= tag or </tool_call>
|
||||
next_func = func_starts[idx + 1].start() if idx + 1 < len(func_starts) else len(content)
|
||||
end_tag = _TC_END_TAG_RE.search(content[body_start:])
|
||||
if end_tag:
|
||||
|
|
@ -107,36 +387,52 @@ def parse_tool_calls_from_text(content: str) -> list[dict]:
|
|||
body_end = len(content)
|
||||
body_end = min(body_end, next_func)
|
||||
body = content[body_start:body_end]
|
||||
body = _TC_FUNC_CLOSE_RE.sub("", body) # trim closing </function>
|
||||
if not allow_incomplete:
|
||||
close_idx = body.rfind(_FUNC_CLOSE_TAG)
|
||||
if close_idx < 0:
|
||||
continue
|
||||
body = body[:close_idx]
|
||||
else:
|
||||
body = _TC_FUNC_CLOSE_RE.sub("", body)
|
||||
|
||||
# Step 2: Extract parameters from body. For single-parameter
|
||||
# functions, use body end as the only boundary to avoid matching
|
||||
# </parameter> inside code strings.
|
||||
arguments = {}
|
||||
arguments: dict = {}
|
||||
param_starts = list(_TC_PARAM_START_RE.finditer(body))
|
||||
if len(param_starts) == 1:
|
||||
# Value is everything after the tag to end of body, less a
|
||||
# trailing </parameter>.
|
||||
pm = param_starts[0]
|
||||
val = body[pm.end() :]
|
||||
val = _TC_PARAM_CLOSE_RE.sub("", val)
|
||||
if not allow_incomplete:
|
||||
stripped_val = val.rstrip()
|
||||
if not stripped_val.endswith(_PARAM_CLOSE_TAG):
|
||||
continue
|
||||
val = stripped_val[: -len(_PARAM_CLOSE_TAG)]
|
||||
else:
|
||||
val = _TC_PARAM_CLOSE_RE.sub("", val)
|
||||
arguments[pm.group(1)] = val.strip()
|
||||
else:
|
||||
valid_params = True
|
||||
for pidx, pm in enumerate(param_starts):
|
||||
param_name = pm.group(1)
|
||||
val_start = pm.end()
|
||||
# Value ends at next <parameter= or end of body
|
||||
next_param = (
|
||||
param_starts[pidx + 1].start()
|
||||
if pidx + 1 < len(param_starts)
|
||||
else len(body)
|
||||
)
|
||||
val = body[val_start:next_param]
|
||||
val = _TC_PARAM_CLOSE_RE.sub("", val) # trim trailing </parameter>
|
||||
if not allow_incomplete:
|
||||
stripped_val = val.rstrip()
|
||||
if not stripped_val.endswith(_PARAM_CLOSE_TAG):
|
||||
valid_params = False
|
||||
break
|
||||
val = stripped_val[: -len(_PARAM_CLOSE_TAG)]
|
||||
else:
|
||||
val = _TC_PARAM_CLOSE_RE.sub("", val)
|
||||
arguments[param_name] = val.strip()
|
||||
if not valid_params:
|
||||
continue
|
||||
|
||||
tc = {
|
||||
"id": f"call_{len(tool_calls)}",
|
||||
"id": f"call_{id_offset + len(tool_calls)}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": func_name,
|
||||
|
|
|
|||
|
|
@ -24,9 +24,10 @@ from datetime import datetime, timezone
|
|||
from loggers import get_logger
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, Any
|
||||
from typing import Optional, Tuple, Any, TYPE_CHECKING
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
if TYPE_CHECKING:
|
||||
import matplotlib.pyplot as plt
|
||||
from utils.hardware import prepare_gpu_selection
|
||||
from utils.native_path_leases import (
|
||||
native_path_secret_removed_for_child_start,
|
||||
|
|
@ -36,6 +37,30 @@ from utils.paths import outputs_root
|
|||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_pyplot = None
|
||||
_pyplot_failed = False
|
||||
|
||||
|
||||
def _load_pyplot():
|
||||
"""Lazily import matplotlib.pyplot (headless Agg); return it, or None if
|
||||
matplotlib is unavailable. Deferred so a blocked native wheel (e.g. Windows
|
||||
Smart App Control) never breaks server startup, only loss plotting.
|
||||
"""
|
||||
global _pyplot, _pyplot_failed
|
||||
if _pyplot is not None or _pyplot_failed:
|
||||
return _pyplot
|
||||
try:
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg") # headless backend
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
_pyplot = plt
|
||||
except Exception as e:
|
||||
_pyplot_failed = True
|
||||
logger.warning("matplotlib unavailable; loss plots disabled", error = str(e))
|
||||
return _pyplot
|
||||
|
||||
|
||||
def _coerce_seed(value, default = 3407) -> int:
|
||||
"""Normalize None / non-int to `default` (transformers.set_seed(None) raises)."""
|
||||
|
|
@ -655,7 +680,7 @@ class TrainingBackend:
|
|||
plot = self._create_loss_plot(progress, theme)
|
||||
return (plot, progress)
|
||||
|
||||
def refresh_plot_for_theme(self, theme: str) -> Optional[plt.Figure]:
|
||||
def refresh_plot_for_theme(self, theme: str) -> "Optional[plt.Figure]":
|
||||
"""Refresh plot with new theme."""
|
||||
if theme and isinstance(theme, str) and theme in ["light", "dark"]:
|
||||
self.current_theme = theme
|
||||
|
|
@ -1090,8 +1115,14 @@ class TrainingBackend:
|
|||
self,
|
||||
progress: TrainingProgress,
|
||||
theme: str = "light",
|
||||
) -> plt.Figure:
|
||||
"""Create training loss plot with theme-aware styling."""
|
||||
) -> "Optional[plt.Figure]":
|
||||
"""Create training loss plot with theme-aware styling.
|
||||
|
||||
matplotlib is loaded lazily; returns None if it is unavailable.
|
||||
"""
|
||||
plt = _load_pyplot()
|
||||
if plt is None:
|
||||
return None
|
||||
plt.close("all")
|
||||
|
||||
LIGHT_STYLE = {
|
||||
|
|
|
|||
|
|
@ -282,6 +282,7 @@ from routes import (
|
|||
training_router,
|
||||
)
|
||||
from routes.llama import router as llama_router
|
||||
from routes.preview import router as preview_router
|
||||
from hub.routes import (
|
||||
inventory_router as hub_inventory_router,
|
||||
datasets_router as hub_datasets_router,
|
||||
|
|
@ -672,6 +673,7 @@ from utils.upload_limits import ( # noqa: E402
|
|||
_BODY_PROTECTED_PREFIXES = (
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/p/",
|
||||
"/api/inference",
|
||||
"/api/data-recipe",
|
||||
"/api/datasets",
|
||||
|
|
@ -855,24 +857,16 @@ async def _recipes_redirect(rest: str = ""):
|
|||
return _RedirectResponse(url = target, status_code = 308)
|
||||
|
||||
|
||||
_api_only = os.environ.get("UNSLOTH_API_ONLY") == "1"
|
||||
_cors_origins = ["*"]
|
||||
if _api_only:
|
||||
_cors_origins = [
|
||||
"tauri://localhost", # Linux/macOS Tauri webview
|
||||
"http://tauri.localhost", # Windows Tauri webview
|
||||
"http://localhost", # dev fallback
|
||||
"http://localhost:5173", # Tauri dev/Vite
|
||||
"http://127.0.0.1:5173", # Tauri dev/Vite fallback
|
||||
]
|
||||
_cors_origin_regex = None
|
||||
else:
|
||||
_cors_origin_regex = None
|
||||
from utils.host_policy import cors_origins_for_mode # noqa: E402
|
||||
|
||||
_cors_origins = cors_origins_for_mode(
|
||||
api_only = os.environ.get("UNSLOTH_API_ONLY") == "1",
|
||||
secure = os.environ.get("UNSLOTH_SECURE") == "1",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins = _cors_origins,
|
||||
allow_origin_regex = _cors_origin_regex,
|
||||
allow_credentials = True,
|
||||
allow_methods = ["*"],
|
||||
allow_headers = ["*"],
|
||||
|
|
@ -893,6 +887,7 @@ app.include_router(inference_studio_router, prefix = "/api/inference", tags = ["
|
|||
|
||||
# OpenAI-compatible: mount the inference router at /v1 for external tools.
|
||||
app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"])
|
||||
app.include_router(preview_router, prefix = "/p", tags = ["preview"])
|
||||
app.include_router(providers_router, prefix = "/api/providers", tags = ["providers"])
|
||||
app.include_router(settings_router, prefix = "/api/settings", tags = ["settings"])
|
||||
app.include_router(mcp_servers_router, prefix = "/api/mcp/servers", tags = ["mcp"])
|
||||
|
|
|
|||
|
|
@ -1102,6 +1102,8 @@ class ChoiceDelta(BaseModel):
|
|||
|
||||
role: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
reasoning_content: Optional[str] = None
|
||||
tool_calls: Optional[list[dict]] = None
|
||||
|
||||
|
||||
OpenAIFinishReason = Literal["stop", "length", "tool_calls", "content_filter", "function_call"]
|
||||
|
|
@ -1137,6 +1139,8 @@ class CompletionMessage(BaseModel):
|
|||
role: Literal["assistant"] = "assistant"
|
||||
content: str
|
||||
refusal: Optional[str] = None
|
||||
reasoning_content: Optional[str] = None
|
||||
tool_calls: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class CompletionChoice(BaseModel):
|
||||
|
|
|
|||
|
|
@ -601,6 +601,8 @@ class TrainingRunSummary(BaseModel):
|
|||
loss_sparkline: Optional[List[float]] = None
|
||||
can_resume: bool = False
|
||||
resumed_later: bool = False
|
||||
has_preview_model: bool = False
|
||||
preview_ref: Optional[str] = None
|
||||
|
||||
|
||||
class TrainingRunUpdateRequest(BaseModel):
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@ peft==0.18.1
|
|||
|
||||
# TRL and related packages
|
||||
trl==0.23.1
|
||||
git+https://github.com/meta-pytorch/OpenEnv.git
|
||||
# executorch>=1.0.1 # 41.5 MB - no imports in unsloth/zoo/studio
|
||||
torch-c-dlpack-ext
|
||||
sentence_transformers==5.2.0
|
||||
transformers==4.57.6
|
||||
pytorch_tokenizers
|
||||
kernels==0.12.1
|
||||
# kernels<3.11 imports tomli as its tomllib fallback; --no-deps skips its own
|
||||
# marker dep, so list it here (no-op on the 3.12/3.13 default installs).
|
||||
tomli; python_version < "3.11"
|
||||
|
|
|
|||
|
|
@ -1,27 +1,11 @@
|
|||
# OpenEnv dependencies
|
||||
tomli
|
||||
tomli-w
|
||||
|
||||
# ExecuTorch dependencies
|
||||
ruamel.yaml
|
||||
# coremltools # 10.2 MB - Apple CoreML, no imports in unsloth/zoo/studio
|
||||
expecttest
|
||||
# transitive dep of onnxruntime (via data-designer's pymupdf4llm)
|
||||
flatbuffers
|
||||
hydra-core
|
||||
hypothesis
|
||||
kgb
|
||||
parameterized
|
||||
pytest>=9.0.3,<10
|
||||
pytest-json-report
|
||||
pytest-rerunfailures>=16.2,<17
|
||||
pytest-xdist
|
||||
# Also needed by sentence_transformers (installed with --no-deps in extras-no-deps.txt)
|
||||
# Also needed by sentence_transformers (installed with --no-deps in extras-no-deps.txt);
|
||||
# librosa pulls it in too, but is skipped in no-torch mode.
|
||||
scikit-learn==1.7.1
|
||||
|
||||
# Additional extras
|
||||
pybind11
|
||||
langid
|
||||
jiwer
|
||||
jiwer # WER/CER metrics for vision OCR save-merge benchmarks
|
||||
omegaconf
|
||||
einx
|
||||
pyloudnorm
|
||||
|
|
@ -39,17 +23,12 @@ ftfy
|
|||
importlib-resources
|
||||
librosa
|
||||
markdown2
|
||||
matplotlib
|
||||
matplotlib==3.10.9
|
||||
pystoi
|
||||
soundfile
|
||||
tensorboard
|
||||
torch-stoi
|
||||
evaluate
|
||||
timm
|
||||
transformers-cfg
|
||||
open_spiel
|
||||
addict
|
||||
easydict
|
||||
einops
|
||||
tabulate
|
||||
openai>=2.7.2
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ httpx
|
|||
httpcore
|
||||
certifi
|
||||
idna
|
||||
anyio>=3.0,<4.14.0 # one consistent <4.14: 4.14's TaskHandle importers over a stale 4.13 _core/_tasks -> ImportError (#6483)
|
||||
anyio>=3.0,<4.14.0 # 4.14 asyncio cancel-scope RuntimeError on Py3.13 streaming (#6483); 4.13 unaffected
|
||||
sniffio
|
||||
h11
|
||||
|
||||
|
|
|
|||
|
|
@ -8,16 +8,16 @@ huggingface-hub==0.36.2
|
|||
datasets==4.3.0
|
||||
pyarrow==23.0.1
|
||||
|
||||
# FastMCP/OpenEnv compat
|
||||
# FastMCP compat
|
||||
fastmcp>=3.0.2
|
||||
mcp>=1.24,<2
|
||||
websockets>=15.0.1
|
||||
|
||||
# Keep anyio on one consistent <4.14 line. anyio 4.14 added TaskHandle (imported
|
||||
# by __init__.py and the asyncio backend from _core/_tasks); a clean 4.14 is fine
|
||||
# on 3.13. The real failure (#6483) is a half-resolved install: a stale 4.13
|
||||
# _core/_tasks (no TaskHandle) under 4.14's importers raises ImportError and 500s
|
||||
# the server. Global cap so later with-deps steps can't re-resolve it up.
|
||||
# Cap anyio <4.14: 4.14's new asyncio per-task cancel scope (TaskHandle/_run_coro)
|
||||
# gets exited in the wrong task on Python 3.13 under starlette's collapsing task
|
||||
# group, raising "RuntimeError: ... exit a cancel scope that isn't the current
|
||||
# task's" on streaming responses (#6483); 4.13 has no such code. Global cap so
|
||||
# later with-deps steps can't re-resolve it up.
|
||||
anyio<4.14.0
|
||||
|
||||
pandas==2.3.3
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@
|
|||
# happens at runtime via the side-car venvs.
|
||||
transformers>=4.57.6
|
||||
|
||||
# mlx-vlm / mlx-lm pull anyio>=4.14, which fights the constraints.txt cap
|
||||
# (anyio<4.14.0). The -c constraint loses that fight on macOS-arm, leaving a
|
||||
# half-resolved anyio (4.14 importers over a stale 4.13 _core/_tasks with no
|
||||
# TaskHandle) that ImportErrors and 500s the server (#6483; clean 4.14 is fine,
|
||||
# it is the mix that breaks). An override wins the fight, so force one
|
||||
# consistent <4.14 here too.
|
||||
# mlx-vlm / mlx-lm pull anyio>=4.14, which fights the constraints.txt cap (needed
|
||||
# for the 4.14 Python-3.13 streaming cancel-scope RuntimeError, #6483). The -c
|
||||
# constraint loses that fight on macOS-arm, leaving a half-resolved 4.14/4.13
|
||||
# anyio that also ImportErrors on TaskHandle and 500s the server. An override
|
||||
# wins the fight, so force one consistent <4.14 here too.
|
||||
anyio<4.14.0
|
||||
|
|
|
|||
|
|
@ -4,13 +4,11 @@ fastapi
|
|||
uvicorn
|
||||
pydantic
|
||||
packaging
|
||||
matplotlib
|
||||
matplotlib==3.10.9
|
||||
pandas
|
||||
nest_asyncio
|
||||
datasets==4.3.0
|
||||
pyjwt
|
||||
easydict
|
||||
addict
|
||||
# gradio>=4.0.0 # 148 MB - Studio uses React + FastAPI, not Gradio
|
||||
huggingface-hub==0.36.2
|
||||
structlog>=24.1.0
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
196
studio/backend/routes/preview.py
Normal file
196
studio/backend/routes/preview.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Per-checkpoint preview endpoints: /p/{run}[/{checkpoint}]/v1/..."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
|
||||
from loggers import get_logger
|
||||
|
||||
from auth.authentication import get_current_subject
|
||||
from auth.storage import DEFAULT_ADMIN_USERNAME
|
||||
from models.inference import ChatCompletionRequest, LoadRequest
|
||||
from routes.inference import load_model, openai_chat_completions
|
||||
from state.tool_policy import tools_force_disabled
|
||||
from utils.models.checkpoints import list_preview_targets, resolve_preview_checkpoint
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Public (no key); resolve_preview_checkpoint pins `run` under outputs_root.
|
||||
# One model loads at a time, so serialize load+generate across previews.
|
||||
_preview_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _resolve_or_4xx(run: str, checkpoint: str | None):
|
||||
try:
|
||||
return resolve_preview_checkpoint(run, checkpoint)
|
||||
except ValueError as exc:
|
||||
# Detail can carry the absolute install path on a symlink escape; log it,
|
||||
# return a generic message on this public route.
|
||||
logger.warning("preview path rejected: %s", exc)
|
||||
raise HTTPException(status_code = 400, detail = "Invalid run or checkpoint")
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code = 404, detail = str(exc))
|
||||
|
||||
|
||||
def _sanitize_preview_payload(
|
||||
payload: ChatCompletionRequest, is_lora: bool
|
||||
) -> ChatCompletionRequest:
|
||||
# Public surface: strip tools/MCP + provider routing (no host code / open proxy).
|
||||
# Normalize use_adapter (never trust the caller): pin True for LoRA, None for
|
||||
# merged. _apply_adapter_state mutates the shared model without restoring, so an
|
||||
# unpinned `false` would persist to later visitors who omit the field.
|
||||
return payload.model_copy(
|
||||
update = {
|
||||
"tools": None,
|
||||
"enable_tools": False,
|
||||
"enabled_tools": None,
|
||||
"mcp_enabled": False,
|
||||
"bypass_permissions": False,
|
||||
"confirm_tool_calls": False,
|
||||
"session_id": None,
|
||||
"rag_scope": None,
|
||||
"openai_code_exec_container_id": None,
|
||||
"anthropic_code_exec_container_id": None,
|
||||
"provider_id": None,
|
||||
"provider_type": None,
|
||||
"external_model": None,
|
||||
"encrypted_api_key": None,
|
||||
"provider_base_url": None,
|
||||
"use_adapter": True if is_lora else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _unlock_after(body_iterator):
|
||||
# Hold the lock until the stream drains so another checkpoint can't swap mid-stream.
|
||||
try:
|
||||
async for chunk in body_iterator:
|
||||
yield chunk
|
||||
finally:
|
||||
_preview_lock.release()
|
||||
|
||||
|
||||
async def _serve_chat(
|
||||
run: str, checkpoint: str | None, payload: ChatCompletionRequest, request: Request
|
||||
):
|
||||
path = _resolve_or_4xx(run, checkpoint)
|
||||
is_lora = (path / "adapter_config.json").exists()
|
||||
payload = _sanitize_preview_payload(payload, is_lora)
|
||||
await _preview_lock.acquire()
|
||||
keep_locked = False
|
||||
try:
|
||||
await load_model(LoadRequest(model_path = str(path)), request, DEFAULT_ADMIN_USERNAME)
|
||||
# Beats a process-wide `--enable-tools` (enable_tools=False alone wouldn't).
|
||||
with tools_force_disabled():
|
||||
response = await openai_chat_completions(payload, request, DEFAULT_ADMIN_USERNAME)
|
||||
if isinstance(response, StreamingResponse):
|
||||
response.body_iterator = _unlock_after(response.body_iterator)
|
||||
keep_locked = True
|
||||
return response
|
||||
finally:
|
||||
if not keep_locked:
|
||||
_preview_lock.release()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_previews(request: Request, current_subject: str = Depends(get_current_subject)):
|
||||
base = str(request.base_url)
|
||||
previews = []
|
||||
for target in list_preview_targets():
|
||||
ref = quote(target["ref"], safe = "/")
|
||||
previews.append({**target, "url": f"{base}p/{ref}/v1"})
|
||||
return {"object": "list", "data": previews}
|
||||
|
||||
|
||||
@router.post("/{run}/v1/chat/completions")
|
||||
async def preview_chat_latest(run: str, payload: ChatCompletionRequest, request: Request):
|
||||
return await _serve_chat(run, None, payload, request)
|
||||
|
||||
|
||||
@router.post("/{run}/{checkpoint}/v1/chat/completions")
|
||||
async def preview_chat_checkpoint(
|
||||
run: str, checkpoint: str, payload: ChatCompletionRequest, request: Request
|
||||
):
|
||||
return await _serve_chat(run, checkpoint, payload, request)
|
||||
|
||||
|
||||
def _models_response(run: str, checkpoint: str | None):
|
||||
path = _resolve_or_4xx(run, checkpoint)
|
||||
model_id = run if not checkpoint else f"{run}/{checkpoint}"
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": model_id,
|
||||
"object": "model",
|
||||
"created": int(path.stat().st_mtime),
|
||||
"owned_by": "unsloth-studio",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{run}/v1/models")
|
||||
async def preview_models_latest(run: str):
|
||||
return _models_response(run, None)
|
||||
|
||||
|
||||
@router.get("/{run}/{checkpoint}/v1/models")
|
||||
async def preview_models_checkpoint(run: str, checkpoint: str):
|
||||
return _models_response(run, checkpoint)
|
||||
|
||||
|
||||
# Serve logo/fonts here too: the SPA static mount is absent in --api-only (Tauri).
|
||||
_FRONTEND_DIST = (Path(__file__).resolve().parents[2] / "frontend" / "dist").resolve()
|
||||
_PREVIEW_ASSET_MEDIA_TYPES = {
|
||||
".png": "image/png",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/_assets/{asset_path:path}")
|
||||
async def preview_asset(asset_path: str):
|
||||
target = (_FRONTEND_DIST / asset_path).resolve()
|
||||
media_type = _PREVIEW_ASSET_MEDIA_TYPES.get(target.suffix.lower())
|
||||
if media_type is None or not target.is_relative_to(_FRONTEND_DIST) or not target.is_file():
|
||||
raise HTTPException(status_code = 404, detail = "Not found")
|
||||
return FileResponse(target, media_type = media_type)
|
||||
|
||||
|
||||
# Self-contained public page; only the title is interpolated.
|
||||
_PREVIEW_PAGE_HTML = (
|
||||
Path(__file__).resolve().parent.parent / "assets" / "preview_page.html"
|
||||
).read_text(encoding = "utf-8")
|
||||
|
||||
_PREVIEW_PAGE_CSP = (
|
||||
"default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
|
||||
"img-src 'self'; font-src 'self'; connect-src 'self'; base-uri 'none'"
|
||||
)
|
||||
|
||||
|
||||
def _preview_page(run: str, checkpoint: str | None) -> HTMLResponse:
|
||||
_resolve_or_4xx(run, checkpoint)
|
||||
title = run if not checkpoint else f"{run}/{checkpoint}"
|
||||
page = _PREVIEW_PAGE_HTML.replace("__TITLE__", html.escape(title))
|
||||
return HTMLResponse(page, headers = {"Content-Security-Policy": _PREVIEW_PAGE_CSP})
|
||||
|
||||
|
||||
@router.get("/{run}", response_class = HTMLResponse)
|
||||
async def preview_page_latest(run: str):
|
||||
return _preview_page(run, None)
|
||||
|
||||
|
||||
@router.get("/{run}/{checkpoint}", response_class = HTMLResponse)
|
||||
async def preview_page_checkpoint(run: str, checkpoint: str):
|
||||
return _preview_page(run, checkpoint)
|
||||
|
|
@ -27,6 +27,7 @@ from storage.studio_db import (
|
|||
list_runs,
|
||||
update_run_display_name,
|
||||
)
|
||||
from utils.models.checkpoints import has_preview_model, preview_ref
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
|
@ -42,7 +43,17 @@ async def list_training_runs(
|
|||
"""List training runs, newest first."""
|
||||
result = list_runs(limit = limit, offset = offset)
|
||||
return TrainingRunListResponse(
|
||||
runs = [TrainingRunSummary(**{**r, "can_resume": can_resume_run(r)}) for r in result["runs"]],
|
||||
runs = [
|
||||
TrainingRunSummary(
|
||||
**{
|
||||
**r,
|
||||
"can_resume": can_resume_run(r),
|
||||
"has_preview_model": has_preview_model(r.get("output_dir")),
|
||||
"preview_ref": preview_ref(r.get("output_dir")),
|
||||
}
|
||||
)
|
||||
for r in result["runs"]
|
||||
],
|
||||
total = result["total"],
|
||||
)
|
||||
|
||||
|
|
@ -67,6 +78,8 @@ async def get_training_run_detail(run_id: str, current_subject: str = Depends(ge
|
|||
**{
|
||||
**{k: v for k, v in run.items() if k != "config_json"},
|
||||
"can_resume": can_resume_run(run),
|
||||
"has_preview_model": has_preview_model(run.get("output_dir")),
|
||||
"preview_ref": preview_ref(run.get("output_dir")),
|
||||
}
|
||||
),
|
||||
config = config,
|
||||
|
|
@ -98,6 +111,8 @@ async def update_training_run(
|
|||
**{
|
||||
**{k: v for k, v in refreshed.items() if k != "config_json"},
|
||||
"can_resume": can_resume_run(refreshed),
|
||||
"has_preview_model": has_preview_model(refreshed.get("output_dir")),
|
||||
"preview_ref": preview_ref(refreshed.get("output_dir")),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -340,34 +340,19 @@ def _verify_global_reachability(display_host: str, port: int) -> None:
|
|||
f"the public internet ({err_nodes}/{total} probe nodes failed).{reset}",
|
||||
flush = True,
|
||||
)
|
||||
print(f"{dim} Common causes:{reset}", flush = True)
|
||||
print(
|
||||
f"{dim} * AWS -- the instance's Security Group doesn't "
|
||||
f"allow inbound TCP {port}.{reset}",
|
||||
f"{dim} Usually a cloud firewall (AWS security group, "
|
||||
f"GCP firewall / Azure NSG rule) or home router isn't "
|
||||
f"allowing inbound TCP {port}.{reset}",
|
||||
flush = True,
|
||||
)
|
||||
print(
|
||||
f"{dim} * GCP -- no firewall rule allowing TCP {port} "
|
||||
f"for the instance's network tag.{reset}",
|
||||
f"{dim} No firewall change needed -- SSH local-forward "
|
||||
f"from your own computer:{reset}",
|
||||
flush = True,
|
||||
)
|
||||
print(
|
||||
f"{dim} * Azure / other clouds -- equivalent NSG / "
|
||||
f"firewall rule missing.{reset}",
|
||||
flush = True,
|
||||
)
|
||||
print(
|
||||
f"{dim} * Home -- your router isn't port-forwarding "
|
||||
f"{port} to this machine.{reset}",
|
||||
flush = True,
|
||||
)
|
||||
print(
|
||||
f"{dim} Workaround that needs no firewall changes -- "
|
||||
f"SSH local-forward from your laptop:{reset}",
|
||||
flush = True,
|
||||
)
|
||||
print(
|
||||
f"{dim} ssh -L {port}:localhost:{port} " f"<user>@{display_host}{reset}",
|
||||
f"{dim} ssh -L {port}:localhost:{port} <user>@{display_host}{reset}",
|
||||
flush = True,
|
||||
)
|
||||
print(
|
||||
|
|
@ -622,7 +607,7 @@ def _graceful_shutdown(server = None):
|
|||
Windows where atexit handlers are unreliable after Ctrl+C.
|
||||
"""
|
||||
_remove_pid_file()
|
||||
logger.info("Graceful shutdown initiated — cleaning up subprocesses...")
|
||||
logger.info("Graceful shutdown initiated -- cleaning up subprocesses...")
|
||||
|
||||
# 1. Shut down uvicorn (releases the listening socket).
|
||||
if server is not None:
|
||||
|
|
@ -893,9 +878,14 @@ def _setup_server_disk_logging():
|
|||
def _cloudflare_tunnel_should_start(
|
||||
*, cloudflare: bool, host: str, secure: bool, api_only: bool, is_colab: bool
|
||||
) -> bool:
|
||||
"""Whether to start the Cloudflare tunnel. --secure tunnels a loopback bind too;
|
||||
non-secure keeps the 0.0.0.0-only rule. Colab/api-only never tunnel."""
|
||||
return cloudflare and (host == "0.0.0.0" or secure) and not api_only and not is_colab
|
||||
"""Whether to start the Cloudflare tunnel. --secure exposes only the tunnel
|
||||
(loopback bind), so it tunnels even api-only (headless secure API serving);
|
||||
otherwise tunnel only a 0.0.0.0 bind, never api-only (Tauri) or Colab."""
|
||||
if is_colab or not cloudflare:
|
||||
return False
|
||||
if secure:
|
||||
return True
|
||||
return host == "0.0.0.0" and not api_only
|
||||
|
||||
|
||||
def _apply_cli_tool_policy(enable_tools: "Optional[bool]") -> None:
|
||||
|
|
@ -919,6 +909,7 @@ def run_server(
|
|||
cloudflare: bool = True,
|
||||
secure: bool = False,
|
||||
enable_tools: "Optional[bool]" = None,
|
||||
emit_tauri_port: bool = True,
|
||||
):
|
||||
"""
|
||||
Start the FastAPI server.
|
||||
|
|
@ -932,6 +923,9 @@ def run_server(
|
|||
llama_parallel_slots: parallel slots for llama-server
|
||||
enable_tools: explicit --enable-tools/--disable-tools policy; None leaves
|
||||
the default (tools on, per-request enable_tools honored)
|
||||
emit_tauri_port: print the machine-readable TAURI_PORT line the desktop
|
||||
app parses from stdout; the headless `run --api-only` path turns it
|
||||
off so it does not pollute the documented URL/API-key banner
|
||||
|
||||
Note:
|
||||
Signal handlers are NOT registered here so embedders (e.g. Colab) keep
|
||||
|
|
@ -974,9 +968,13 @@ def run_server(
|
|||
if _session_log is not None and not silent:
|
||||
print(f"Session log: {_session_log}")
|
||||
|
||||
# Set env var BEFORE importing main so CORS middleware picks it up.
|
||||
# Set env vars BEFORE importing main so CORS middleware picks them up.
|
||||
# secure api-only is a remote server behind Cloudflare, so it keeps the
|
||||
# any-origin CORS profile; plain api-only stays locked to the Tauri app.
|
||||
if api_only:
|
||||
os.environ["UNSLOTH_API_ONLY"] = "1"
|
||||
if secure:
|
||||
os.environ["UNSLOTH_SECURE"] = "1"
|
||||
|
||||
import nest_asyncio
|
||||
|
||||
|
|
@ -1097,7 +1095,10 @@ def run_server(
|
|||
app.state.server_port = port if port and port > 0 else None
|
||||
# Direct (non-tunnel) base for the API panel; resolve 0.0.0.0 to the LAN IP.
|
||||
if port and port > 0:
|
||||
_direct_host = _resolve_external_ip() if host == "0.0.0.0" else host
|
||||
_direct_host = _resolve_external_ip() if host in ("0.0.0.0", "::") else host
|
||||
# Bracket IPv6 literals so the URL is valid (http://[2405:...]:port).
|
||||
if ":" in _direct_host and not _direct_host.startswith("["):
|
||||
_direct_host = f"[{_direct_host}]"
|
||||
app.state.server_url = f"http://{_direct_host}:{port}"
|
||||
else:
|
||||
app.state.server_url = None
|
||||
|
|
@ -1158,7 +1159,8 @@ def run_server(
|
|||
atexit.register(terminate_all)
|
||||
|
||||
# Output port for Tauri (api-only), only after sockets bind and startup done.
|
||||
if api_only:
|
||||
# The headless `run --api-only` path opts out so it does not leak this line.
|
||||
if api_only and emit_tauri_port:
|
||||
print(f"TAURI_PORT={port}", flush = True)
|
||||
|
||||
# Free trycloudflare.com tunnel for 0.0.0.0 binds (the raw ip:port is often
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
"""Terminal banner for Studio startup.
|
||||
|
||||
Stdlib only — safe to import without the rest of the backend.
|
||||
Stdlib only -- safe to import without the rest of the backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -49,10 +49,10 @@ def print_studio_stop_hint() -> None:
|
|||
[
|
||||
"",
|
||||
style(
|
||||
" To stop Unsloth Studio: press Ctrl+C in this terminal.",
|
||||
" To stop Unsloth Studio: press Ctrl+C "
|
||||
"(Control+C, not Command+C, on macOS).",
|
||||
stop_hint_style,
|
||||
),
|
||||
style(" (On macOS this is Control+C, not Command+C.)", dim),
|
||||
style("─" * 52, dim),
|
||||
"",
|
||||
]
|
||||
|
|
@ -101,7 +101,6 @@ def print_studio_access_banner(
|
|||
# Use the loopback URL only when reachable on loopback; otherwise show
|
||||
# the actual bound address.
|
||||
primary_url = loopback_url if listen_all or loopback_bind else external_url
|
||||
tip_url = alt_local if listen_all or loopback_bind else external_url
|
||||
api_base = primary_url
|
||||
|
||||
lines: list[str] = [
|
||||
|
|
@ -145,10 +144,6 @@ def print_studio_access_banner(
|
|||
style(f" {api_base}/api", secondary),
|
||||
style(f" {api_base}/api/health", secondary),
|
||||
style("─" * 52, dim),
|
||||
style(
|
||||
f" Tip: if you are on this computer, open {tip_url}/ in your browser.",
|
||||
dim,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -157,23 +152,15 @@ def print_studio_access_banner(
|
|||
[
|
||||
"",
|
||||
style(
|
||||
" Studio is only reachable on this machine (bound to 127.0.0.1).",
|
||||
" Reachable on this machine only (bound to 127.0.0.1).",
|
||||
secondary,
|
||||
),
|
||||
style(
|
||||
" To deploy and access globally:",
|
||||
f" To expose it, stop and relaunch with: unsloth studio -H 0.0.0.0 -p {port}",
|
||||
secondary,
|
||||
),
|
||||
style(
|
||||
" 1. press Ctrl+C to stop Studio",
|
||||
secondary,
|
||||
),
|
||||
style(
|
||||
f" 2. relaunch with: unsloth studio -H 0.0.0.0 -p {port}",
|
||||
secondary,
|
||||
),
|
||||
style(
|
||||
" Only do this on trusted networks -- it exposes the API on every interface.",
|
||||
" Only on trusted networks -- anyone who reaches this machine can use Studio.",
|
||||
secondary,
|
||||
),
|
||||
]
|
||||
|
|
@ -184,10 +171,10 @@ def print_studio_access_banner(
|
|||
[
|
||||
"",
|
||||
style(
|
||||
" To stop Unsloth Studio: press Ctrl+C in this terminal.",
|
||||
" To stop Unsloth Studio: press Ctrl+C "
|
||||
"(Control+C, not Command+C, on macOS).",
|
||||
stop_hint_style,
|
||||
),
|
||||
style(" (On macOS this is Control+C, not Command+C.)", dim),
|
||||
style("─" * 52, dim),
|
||||
"",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -10,15 +10,34 @@ Set by `unsloth run` at startup; consulted by the inference route gates.
|
|||
False -> CLI forced tools off for every request.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
import contextvars
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator, Optional
|
||||
|
||||
_tool_policy: Optional[bool] = None
|
||||
|
||||
# Per-request hard-off so public surfaces refuse tools even under a CLI `--enable-tools`.
|
||||
_force_disabled: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
||||
"tool_policy_force_disabled", default = False
|
||||
)
|
||||
|
||||
|
||||
def get_tool_policy() -> Optional[bool]:
|
||||
if _force_disabled.get():
|
||||
return False
|
||||
return _tool_policy
|
||||
|
||||
|
||||
@contextmanager
|
||||
def tools_force_disabled() -> Iterator[None]:
|
||||
"""Hard-disable server-side tools for the current async context."""
|
||||
token = _force_disabled.set(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_force_disabled.reset(token)
|
||||
|
||||
|
||||
def set_tool_policy(value: Optional[bool]) -> None:
|
||||
if value is not None and not isinstance(value, bool):
|
||||
raise TypeError(f"tool_policy must be Optional[bool], got {type(value).__name__}")
|
||||
|
|
|
|||
161
studio/backend/tests/test_gemma_tool_parse_edge_cases.py
Normal file
161
studio/backend/tests/test_gemma_tool_parse_edge_cases.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Edge cases in Gemma-native tool-call parsing.
|
||||
|
||||
Covers two failure modes:
|
||||
1. A bare (unquoted) string argument that contains a comma, e.g.
|
||||
``location:New York, NY`` -- the comma must not be treated as the next
|
||||
key boundary, or the whole call is dropped.
|
||||
2. A tool-call marker that appears INSIDE another call's argument string is
|
||||
data, not a real call, so it must not be promoted to a second tool call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
||||
if _BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, _BACKEND_DIR)
|
||||
|
||||
from core.inference.tool_call_parser import parse_tool_calls_from_text
|
||||
|
||||
|
||||
def _args(call: dict) -> dict:
|
||||
return json.loads(call["function"]["arguments"])
|
||||
|
||||
|
||||
def test_bare_string_argument_with_comma_is_kept():
|
||||
calls = parse_tool_calls_from_text(
|
||||
"<|tool_call>call:get_weather{location:New York, NY,unit:celsius}<tool_call|>"
|
||||
)
|
||||
assert len(calls) == 1, calls
|
||||
assert calls[0]["function"]["name"] == "get_weather"
|
||||
assert _args(calls[0]) == {"location": "New York, NY", "unit": "celsius"}
|
||||
|
||||
|
||||
def test_normal_multi_key_arguments_still_split():
|
||||
calls = parse_tool_calls_from_text('<|tool_call>call:f{a:1,b:hello,c:"x,y"}<tool_call|>')
|
||||
assert len(calls) == 1, calls
|
||||
# Numbers stay numeric, bare strings get quoted, an explicit quoted comma
|
||||
# stays inside its value.
|
||||
assert _args(calls[0]) == {"a": 1, "b": "hello", "c": "x,y"}
|
||||
|
||||
|
||||
def test_bare_value_with_timestamps_after_comma_is_kept():
|
||||
# A comma followed by digits-then-colon (a timestamp/ratio) is value text,
|
||||
# not a new key, so the whole query must be preserved as one argument.
|
||||
calls = parse_tool_calls_from_text(
|
||||
"<|tool_call>call:remind{query:meet at 10:00, 11:00 tomorrow,priority:high}<tool_call|>"
|
||||
)
|
||||
assert len(calls) == 1, calls
|
||||
assert _args(calls[0]) == {"query": "meet at 10:00, 11:00 tomorrow", "priority": "high"}
|
||||
|
||||
|
||||
def test_marker_inside_json_argument_is_not_a_second_call():
|
||||
# A python call whose `code` argument contains a Gemma marker string. The
|
||||
# marker is data and must not execute as a second `terminal` call.
|
||||
content = (
|
||||
'<tool_call>{"name":"python","arguments":{"code":'
|
||||
'"x = 1 # <|tool_call>call:terminal{command:ls}<tool_call|>"}}</tool_call>'
|
||||
)
|
||||
calls = parse_tool_calls_from_text(content)
|
||||
assert [c["function"]["name"] for c in calls] == ["python"], calls
|
||||
|
||||
|
||||
def test_two_separate_gemma_calls_both_parse():
|
||||
content = "<|tool_call>call:a{x:1}<tool_call|> and <|tool_call>call:b{y:2}<tool_call|>"
|
||||
calls = parse_tool_calls_from_text(content)
|
||||
assert [c["function"]["name"] for c in calls] == ["a", "b"], calls
|
||||
assert _args(calls[0]) == {"x": 1}
|
||||
assert _args(calls[1]) == {"y": 2}
|
||||
|
||||
|
||||
def test_mixed_format_calls_preserve_document_order():
|
||||
# A Gemma-native call precedes a JSON-format call in the text; tools execute
|
||||
# in returned order, so `create` must come before `read`.
|
||||
content = (
|
||||
"<|tool_call>call:create{path:a}<tool_call|> then "
|
||||
'<tool_call>{"name":"read","arguments":{"path":"a"}}</tool_call>'
|
||||
)
|
||||
calls = parse_tool_calls_from_text(content)
|
||||
assert [c["function"]["name"] for c in calls] == ["create", "read"], calls
|
||||
|
||||
|
||||
def test_json_marker_inside_gemma_argument_is_not_a_second_call():
|
||||
# The reverse of the JSON-outer case: a JSON-style marker inside a Gemma
|
||||
# call's quoted argument is code text, not a second `terminal` call.
|
||||
content = (
|
||||
'<|tool_call>call:python{code:<|"|>'
|
||||
'print(<tool_call>{"name":"terminal","arguments":{"command":"ls"}}</tool_call>)'
|
||||
'<|"|>}<tool_call|>'
|
||||
)
|
||||
calls = parse_tool_calls_from_text(content)
|
||||
assert [c["function"]["name"] for c in calls] == ["python"], calls
|
||||
|
||||
|
||||
def test_nested_gemma_marker_in_unquoted_arg_does_not_run_inner_call():
|
||||
# An UNQUOTED Gemma value containing a literal marker: the outer object fails
|
||||
# to normalize (the inner braces/marker break the JSON), but the inner marker
|
||||
# is nested in the outer candidate span, so it must not be promoted to a
|
||||
# standalone `terminal` call. The safe outcome is no executed tool call.
|
||||
content = "<|tool_call>call:python{code:<|tool_call>call:terminal{command:ls}<tool_call|>}<tool_call|>"
|
||||
calls = parse_tool_calls_from_text(content)
|
||||
assert "terminal" not in [c["function"]["name"] for c in calls], calls
|
||||
|
||||
|
||||
def test_bare_string_array_argument_is_quoted():
|
||||
# Gemma may emit an array of bare strings without per-element quotes; they
|
||||
# must be quoted so the call is not dropped.
|
||||
calls = parse_tool_calls_from_text("<|tool_call>call:label{labels:[bug,ui]}<tool_call|>")
|
||||
assert len(calls) == 1, calls
|
||||
assert _args(calls[0]) == {"labels": ["bug", "ui"]}
|
||||
|
||||
|
||||
def test_array_keeps_numbers_and_quoted_elements():
|
||||
calls = parse_tool_calls_from_text(
|
||||
'<|tool_call>call:f{nums:[1,2],tags:[<|"|>a,b<|"|>,c]}<tool_call|>'
|
||||
)
|
||||
assert _args(calls[0]) == {"nums": [1, 2], "tags": ["a,b", "c"]}
|
||||
|
||||
|
||||
def test_array_of_objects_is_normalised():
|
||||
# Arrays of objects are a common tool-schema shape; their (unquoted) keys and
|
||||
# bare values must be normalised too, not left verbatim, or the call drops.
|
||||
calls = parse_tool_calls_from_text(
|
||||
"<|tool_call>call:batch{items:[{path:a,mode:r},{path:b,mode:w}]}<tool_call|>"
|
||||
)
|
||||
assert len(calls) == 1, calls
|
||||
assert _args(calls[0]) == {"items": [{"path": "a", "mode": "r"}, {"path": "b", "mode": "w"}]}
|
||||
|
||||
|
||||
def test_nested_array_elements_are_normalised():
|
||||
calls = parse_tool_calls_from_text("<|tool_call>call:grid{cells:[[a,b],[c,d]]}<tool_call|>")
|
||||
assert _args(calls[0]) == {"cells": [["a", "b"], ["c", "d"]]}
|
||||
|
||||
|
||||
def test_gemma_marker_inside_xml_parameter_is_not_a_second_call():
|
||||
# An XML-style <function=...> call whose <parameter=code> value contains a
|
||||
# Gemma marker: the marker is the parameter's data, not a separate terminal
|
||||
# call, so only the python call must be returned.
|
||||
content = (
|
||||
"<tool_call><function=python><parameter=code>"
|
||||
"x = 1 # <|tool_call>call:terminal{command:ls}<tool_call|>"
|
||||
"</parameter></function></tool_call>"
|
||||
)
|
||||
calls = parse_tool_calls_from_text(content)
|
||||
assert [c["function"]["name"] for c in calls] == ["python"], calls
|
||||
assert "terminal" in _args(calls[0])["code"]
|
||||
|
||||
|
||||
def test_json_marker_inside_xml_parameter_is_not_a_second_call():
|
||||
content = (
|
||||
"<tool_call><function=python><parameter=code>"
|
||||
'run(<tool_call>{"name":"terminal","arguments":{"command":"ls"}}</tool_call>)'
|
||||
"</parameter></function></tool_call>"
|
||||
)
|
||||
calls = parse_tool_calls_from_text(content)
|
||||
assert [c["function"]["name"] for c in calls] == ["python"], calls
|
||||
172
studio/backend/tests/test_gguf_tool_non_streaming.py
Normal file
172
studio/backend/tests/test_gguf_tool_non_streaming.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# 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 `stream:false` on the GGUF agentic tool path (#6570).
|
||||
|
||||
When server-side tools are enabled (e.g. `unsloth studio run --model ...`,
|
||||
which forces the tool policy on process-wide), a plain chat request used to be
|
||||
routed into the tool loop, which returned an SSE body *regardless* of
|
||||
`stream:false` -- breaking non-streaming clients and health checks like
|
||||
LiteLLM. These tests drive the real route with a fake tool-capable backend and
|
||||
assert the non-streaming path now returns a single JSON `chat.completion`,
|
||||
while `stream:true` still streams.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from auth.authentication import get_current_subject
|
||||
import routes.inference as inference_route
|
||||
|
||||
|
||||
class _ToolGgufBackend:
|
||||
is_loaded = True
|
||||
model_identifier = "test/model.gguf"
|
||||
_is_audio = False
|
||||
is_vision = False
|
||||
supports_tools = True
|
||||
|
||||
def generate_chat_completion_with_tools(self, **kwargs):
|
||||
# The agentic loop runs one tool, then the model answers. Event shapes
|
||||
# mirror the real GGUF loop (tool_start/tool_end/content/metadata).
|
||||
yield {
|
||||
"type": "tool_start",
|
||||
"tool_name": "python",
|
||||
"tool_call_id": "call_1",
|
||||
"arguments": {"code": "print(6 * 7)"},
|
||||
}
|
||||
yield {
|
||||
"type": "tool_end",
|
||||
"tool_name": "python",
|
||||
"tool_call_id": "call_1",
|
||||
"result": "42\n",
|
||||
}
|
||||
yield {"type": "content", "text": "The answer is 42."}
|
||||
yield {
|
||||
"type": "metadata",
|
||||
"usage": {"prompt_tokens": 11, "completion_tokens": 5, "total_tokens": 16},
|
||||
"timings": {"prompt_n": 11, "predicted_n": 5},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
|
||||
|
||||
def _client(monkeypatch, backend = None):
|
||||
monkeypatch.setattr(
|
||||
inference_route, "get_llama_cpp_backend", lambda: backend or _ToolGgufBackend()
|
||||
)
|
||||
# Tools forced on -- the same effect as the CLI `run --model` tool policy.
|
||||
monkeypatch.setattr(inference_route, "_effective_enable_tools", lambda payload: True)
|
||||
|
||||
async def _fake_select(payload, **_kwargs):
|
||||
return [{"type": "function", "function": {"name": "python"}}]
|
||||
|
||||
monkeypatch.setattr(inference_route, "_select_request_tools", _fake_select)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(inference_route.router)
|
||||
app.dependency_overrides[get_current_subject] = lambda: "test-user"
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _payload(stream: bool):
|
||||
return {
|
||||
"messages": [{"role": "user", "content": "What is 6 * 7? Use python."}],
|
||||
"stream": stream,
|
||||
"enable_tools": True,
|
||||
}
|
||||
|
||||
|
||||
def test_non_streaming_tool_call_returns_single_json(monkeypatch):
|
||||
response = _client(monkeypatch).post("/chat/completions", json = _payload(stream = False))
|
||||
|
||||
assert response.status_code == 200
|
||||
# The bug returned text/event-stream here; it must be a single JSON object.
|
||||
assert response.headers["content-type"].startswith("application/json")
|
||||
|
||||
body = response.json()
|
||||
assert body["object"] == "chat.completion"
|
||||
choice = body["choices"][0]
|
||||
assert choice["message"]["content"] == "The answer is 42."
|
||||
assert choice["finish_reason"] == "stop"
|
||||
assert body["usage"]["prompt_tokens"] == 11
|
||||
assert body["usage"]["completion_tokens"] == 5
|
||||
assert body["usage"]["total_tokens"] == 16
|
||||
|
||||
|
||||
def test_streaming_tool_call_still_streams(monkeypatch):
|
||||
# The parallel path is untouched: stream:true keeps returning SSE.
|
||||
response = _client(monkeypatch).post("/chat/completions", json = _payload(stream = True))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/event-stream")
|
||||
assert "The answer is 42." in response.text
|
||||
assert "data: [DONE]" in response.text
|
||||
|
||||
|
||||
class _EventsBackend(_ToolGgufBackend):
|
||||
"""Tool backend that yields a caller-supplied event list."""
|
||||
|
||||
def __init__(self, events):
|
||||
self._events = events
|
||||
|
||||
def generate_chat_completion_with_tools(self, **kwargs):
|
||||
yield from self._events
|
||||
|
||||
|
||||
def test_non_streaming_missing_usage_defaults_to_zero(monkeypatch):
|
||||
# No metadata event at all: usage zero-defaults and finish_reason falls back.
|
||||
events = [{"type": "content", "text": "hi"}]
|
||||
response = _client(monkeypatch, _EventsBackend(events)).post(
|
||||
"/chat/completions", json = _payload(stream = False)
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["choices"][0]["message"]["content"] == "hi"
|
||||
assert body["choices"][0]["finish_reason"] == "stop"
|
||||
assert body["usage"]["prompt_tokens"] == 0
|
||||
assert body["usage"]["completion_tokens"] == 0
|
||||
assert body["usage"]["total_tokens"] == 0
|
||||
|
||||
|
||||
def test_non_streaming_preserves_length_finish_reason(monkeypatch):
|
||||
events = [
|
||||
{"type": "content", "text": "truncated"},
|
||||
{
|
||||
"type": "metadata",
|
||||
"usage": {"prompt_tokens": 3, "completion_tokens": 9},
|
||||
"finish_reason": "length",
|
||||
},
|
||||
]
|
||||
response = _client(monkeypatch, _EventsBackend(events)).post(
|
||||
"/chat/completions", json = _payload(stream = False)
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["choices"][0]["finish_reason"] == "length"
|
||||
# total_tokens is derived when the server omits it.
|
||||
assert body["usage"]["total_tokens"] == 12
|
||||
|
||||
|
||||
def test_non_streaming_preserves_cached_tokens(monkeypatch):
|
||||
# KV-cache hit details from the metadata event must survive into the body
|
||||
# (the tool path used to drop them and always report cached_tokens=0).
|
||||
events = [
|
||||
{"type": "content", "text": "hi"},
|
||||
{
|
||||
"type": "metadata",
|
||||
"usage": {
|
||||
"prompt_tokens": 20,
|
||||
"completion_tokens": 4,
|
||||
"prompt_tokens_details": {"cached_tokens": 16},
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
]
|
||||
response = _client(monkeypatch, _EventsBackend(events)).post(
|
||||
"/chat/completions", json = _payload(stream = False)
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["usage"]["prompt_tokens_details"]["cached_tokens"] == 16
|
||||
|
|
@ -209,3 +209,154 @@ def test_resolve_prebuilt_linux_amd_tooling_routes_to_fork(monkeypatch, capsys):
|
|||
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
||||
assert seen["repo"] == FORK
|
||||
assert out["repo"] == FORK
|
||||
|
||||
|
||||
# Blackwell floor is sm_100 (data-center B100/B200, B300/GB300), below consumer
|
||||
# sm_120 -- 120 wrongly excluded data-center hosts from the prebuilt selection.
|
||||
|
||||
|
||||
def _gpu_linux_host(caps):
|
||||
return _host(
|
||||
is_linux = True,
|
||||
is_x86_64 = True,
|
||||
has_physical_nvidia = True,
|
||||
has_usable_nvidia = True,
|
||||
driver_cuda_version = (13, 1),
|
||||
compute_caps = caps,
|
||||
)
|
||||
|
||||
|
||||
def test_host_is_blackwell_includes_datacenter_parts():
|
||||
assert ilp._host_is_blackwell(_gpu_linux_host(["10.0"])) is True # B200 sm_100
|
||||
assert ilp._host_is_blackwell(_gpu_linux_host(["10.3"])) is True # B300 sm_103
|
||||
assert ilp._host_is_blackwell(_gpu_linux_host(["12.0"])) is True # RTX 50 sm_120
|
||||
assert ilp._host_is_blackwell(_gpu_linux_host(["12.1"])) is True # DGX Spark sm_121
|
||||
assert ilp._host_is_blackwell(_gpu_linux_host(["9.0"])) is False # Hopper
|
||||
assert ilp._host_is_blackwell(_gpu_linux_host(["8.0"])) is False # Ampere
|
||||
assert ilp._host_is_blackwell(_gpu_linux_host(["9.0", "10.0"])) is True # highest cap wins
|
||||
|
||||
|
||||
def _linux_cuda_artifact(runtime_line, supported_sms, min_sm, max_sm, profile):
|
||||
return ilp.PublishedLlamaArtifact(
|
||||
asset_name = f"app-b9739-linux-x64-{profile}.tar.gz",
|
||||
install_kind = "linux-cuda",
|
||||
runtime_line = runtime_line,
|
||||
coverage_class = "newer",
|
||||
supported_sms = supported_sms,
|
||||
min_sm = min_sm,
|
||||
max_sm = max_sm,
|
||||
bundle_profile = profile,
|
||||
rank = 50,
|
||||
)
|
||||
|
||||
|
||||
def test_linux_blackwell_override_prefers_cuda13_for_datacenter(monkeypatch):
|
||||
# Both bundles cover sm_100 and torch reports cuda12, so coverage alone can't
|
||||
# decide -- only the sm_100 Blackwell floor lifts cuda13 to the front.
|
||||
cuda12 = _linux_cuda_artifact(
|
||||
"cuda12", ["86", "89", "90", "100", "120"], 86, 120, "cuda12-newer"
|
||||
)
|
||||
cuda13 = _linux_cuda_artifact(
|
||||
"cuda13", ["86", "89", "90", "100", "103", "120"], 86, 120, "cuda13-newer"
|
||||
)
|
||||
release = ilp.PublishedReleaseBundle(
|
||||
repo = FORK,
|
||||
release_tag = "b9739-mix",
|
||||
upstream_tag = "b9739",
|
||||
assets = {cuda12.asset_name: "https://x/cuda12", cuda13.asset_name: "https://x/cuda13"},
|
||||
artifacts = [cuda12, cuda13],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ilp,
|
||||
"detected_linux_runtime_lines",
|
||||
lambda: (["cuda13", "cuda12"], {"cuda13": ["/usr/lib"], "cuda12": ["/usr/lib"]}),
|
||||
)
|
||||
|
||||
selection = ilp.linux_cuda_choice_from_release(
|
||||
_gpu_linux_host(["10.0"]), release, preferred_runtime_line = "cuda12"
|
||||
)
|
||||
assert selection is not None
|
||||
assert selection.primary.runtime_line == "cuda13"
|
||||
assert selection.primary.bundle_profile == "cuda13-newer"
|
||||
|
||||
|
||||
def test_drop_blackwell_incapable_windows_cuda_applies_to_datacenter():
|
||||
# B200 (sm_100) on Windows must drop the cuda-12.4 build and keep cuda13.
|
||||
host = _host(
|
||||
system = "Windows",
|
||||
is_windows = True,
|
||||
is_x86_64 = True,
|
||||
has_physical_nvidia = True,
|
||||
has_usable_nvidia = True,
|
||||
compute_caps = ["10.0"],
|
||||
)
|
||||
cuda124 = ilp.AssetChoice(
|
||||
repo = FORK,
|
||||
tag = "b9739",
|
||||
name = "llama-b9739-bin-win-cuda-12.4-x64.zip",
|
||||
url = "https://x/124",
|
||||
source_label = "published",
|
||||
install_kind = "windows-cuda",
|
||||
)
|
||||
cuda13 = ilp.AssetChoice(
|
||||
repo = FORK,
|
||||
tag = "b9739",
|
||||
name = "app-b9739-windows-x64-cuda13-newer.zip",
|
||||
url = "https://x/13",
|
||||
source_label = "published",
|
||||
install_kind = "windows-cuda",
|
||||
max_sm = 120,
|
||||
)
|
||||
kept = ilp._drop_blackwell_incapable_windows_cuda(host, [cuda124, cuda13])
|
||||
assert [a.name for a in kept] == [cuda13.name]
|
||||
|
||||
|
||||
def test_blackwell_min_toolkit_is_sm_aware():
|
||||
# Family floor is 12.8; sm_103/sm_121 (no native target before 12.9) lift it.
|
||||
f = ilp._blackwell_min_toolkit_for_host
|
||||
assert f(_gpu_linux_host(["10.0"])) == (12, 8) # B200
|
||||
assert f(_gpu_linux_host(["12.0"])) == (12, 8) # RTX 50
|
||||
assert f(_gpu_linux_host(["10.3"])) == (12, 9) # B300
|
||||
assert f(_gpu_linux_host(["12.1"])) == (12, 9) # DGX Spark
|
||||
assert f(_gpu_linux_host(["10.0", "10.3"])) == (12, 9) # max across SMs wins
|
||||
|
||||
|
||||
def test_sm103_host_drops_cuda128_windows_build():
|
||||
# B300 (sm_103) needs cuda-12.9: a legacy win-cuda-12.8 build must be dropped.
|
||||
host = _host(
|
||||
system = "Windows",
|
||||
is_windows = True,
|
||||
is_x86_64 = True,
|
||||
has_physical_nvidia = True,
|
||||
has_usable_nvidia = True,
|
||||
compute_caps = ["10.3"],
|
||||
)
|
||||
cuda128 = ilp.AssetChoice(
|
||||
repo = FORK,
|
||||
tag = "b9739",
|
||||
name = "llama-b9739-bin-win-cuda-12.8-x64.zip",
|
||||
url = "https://x/128",
|
||||
source_label = "published",
|
||||
install_kind = "windows-cuda",
|
||||
)
|
||||
cuda129 = ilp.AssetChoice(
|
||||
repo = FORK,
|
||||
tag = "b9739",
|
||||
name = "llama-b9739-bin-win-cuda-12.9-x64.zip",
|
||||
url = "https://x/129",
|
||||
source_label = "published",
|
||||
install_kind = "windows-cuda",
|
||||
)
|
||||
kept = ilp._drop_blackwell_incapable_windows_cuda(host, [cuda128, cuda129])
|
||||
assert [a.name for a in kept] == [cuda129.name]
|
||||
# sm_100 stays on the 12.8 family floor and keeps the same 12.8 build.
|
||||
b200 = _host(
|
||||
system = "Windows",
|
||||
is_windows = True,
|
||||
is_x86_64 = True,
|
||||
has_physical_nvidia = True,
|
||||
has_usable_nvidia = True,
|
||||
compute_caps = ["10.0"],
|
||||
)
|
||||
kept_b200 = ilp._drop_blackwell_incapable_windows_cuda(b200, [cuda128, cuda129])
|
||||
assert [a.name for a in kept_b200] == [cuda128.name, cuda129.name]
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ _httpx_stub.Client = type(
|
|||
sys.modules.setdefault("httpx", _httpx_stub)
|
||||
|
||||
from core.inference.llama_cpp import (
|
||||
_APPLE_UNIFIED_MEMORY_FRACTION,
|
||||
_CTX_FIT_VRAM_FRACTION,
|
||||
LlamaCppBackend,
|
||||
classify_gpu_offload_lines,
|
||||
|
|
@ -120,6 +121,8 @@ def _drive(
|
|||
kv_per_token_bytes = 325_000,
|
||||
can_estimate_kv = True,
|
||||
extra_args = None,
|
||||
apple_budget_mib = 0,
|
||||
flat_mtp_reserve = 0.0,
|
||||
):
|
||||
"""Drive the post-metadata portion of load_model with stubbed inputs.
|
||||
|
||||
|
|
@ -223,6 +226,32 @@ def _drive(
|
|||
gpu_indices, use_fit = inst._select_gpus(model_size, gpus)
|
||||
if use_fit and not explicit_ctx:
|
||||
effective_ctx = min(FALLBACK_CTX, effective_ctx) if effective_ctx > 0 else FALLBACK_CTX
|
||||
elif apple_budget_mib > 0 and effective_ctx > 0:
|
||||
# Mirrors the Apple unified-memory branch in load_model: flat MTP reserve
|
||||
# off the budget up front (no-op at 0), sparse-KV floors to FALLBACK_CTX,
|
||||
# only auto context shrinks.
|
||||
native_ctx_for_cap = context_length or effective_ctx
|
||||
apple_fit_budget_mib = int(apple_budget_mib * max(0.0, 1.0 - flat_mtp_reserve))
|
||||
if inst._can_estimate_kv():
|
||||
cap = inst._fit_context_to_vram(
|
||||
native_ctx_for_cap,
|
||||
apple_fit_budget_mib,
|
||||
model_size,
|
||||
cache_type_kv,
|
||||
budget_frac = 1.0,
|
||||
)
|
||||
cap_footprint_mib = (model_size + inst._estimate_kv_cache_bytes(cap, cache_type_kv)) / (
|
||||
1024 * 1024
|
||||
)
|
||||
max_available_ctx = (
|
||||
cap
|
||||
if cap_footprint_mib <= apple_fit_budget_mib
|
||||
else min(FALLBACK_CTX, native_ctx_for_cap)
|
||||
)
|
||||
else:
|
||||
max_available_ctx = min(FALLBACK_CTX, native_ctx_for_cap)
|
||||
if not explicit_ctx:
|
||||
effective_ctx = max_available_ctx
|
||||
|
||||
return {
|
||||
"c_arg": effective_ctx if effective_ctx > 0 else 0,
|
||||
|
|
@ -704,3 +733,204 @@ def test_select_gpus_reserves_per_device_overhead():
|
|||
small, gpus, total_by_idx = totals, per_device_overhead_bytes = gib
|
||||
)
|
||||
assert a == [0] and b == [0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apple Silicon unified-memory context cap (#5118, #6529): no discrete GPU on
|
||||
# Metal, so the auto context defaulted to native and over-committed unified
|
||||
# memory. The fix budgets and caps the auto context (explicit stays verbatim).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _force_apple(monkeypatch):
|
||||
import platform as _platform
|
||||
monkeypatch.setattr(_platform, "system", lambda: "Darwin")
|
||||
monkeypatch.setattr(_platform, "machine", lambda: "arm64")
|
||||
|
||||
|
||||
def _install_fake_mlx(monkeypatch, working_set_bytes):
|
||||
"""Minimal mlx.core stub exposing metal.is_available() and device_info()."""
|
||||
mlx = _types.ModuleType("mlx")
|
||||
mlx_core = _types.ModuleType("mlx.core")
|
||||
mlx_core.metal = _types.SimpleNamespace(is_available = lambda: True)
|
||||
mlx_core.device_info = lambda: {"max_recommended_working_set_size": working_set_bytes}
|
||||
mlx.core = mlx_core
|
||||
monkeypatch.setitem(sys.modules, "mlx", mlx)
|
||||
monkeypatch.setitem(sys.modules, "mlx.core", mlx_core)
|
||||
|
||||
|
||||
class TestAppleUnifiedMemoryBudget:
|
||||
def test_zero_off_apple_silicon(self, monkeypatch):
|
||||
import platform as _platform
|
||||
|
||||
monkeypatch.setattr(_platform, "system", lambda: "Linux")
|
||||
monkeypatch.setattr(_platform, "machine", lambda: "x86_64")
|
||||
assert LlamaCppBackend._apple_metal_memory_budget_bytes() == 0
|
||||
|
||||
def test_uses_metal_working_set(self, monkeypatch):
|
||||
_force_apple(monkeypatch)
|
||||
ws = 27 * GIB # ~recommended working set on a 36 GB Mac
|
||||
_install_fake_mlx(monkeypatch, ws)
|
||||
assert LlamaCppBackend._apple_metal_memory_budget_bytes() == int(
|
||||
ws * _APPLE_UNIFIED_MEMORY_FRACTION
|
||||
)
|
||||
|
||||
def test_falls_back_to_total_ram_without_mlx(self, monkeypatch):
|
||||
_force_apple(monkeypatch)
|
||||
monkeypatch.setitem(sys.modules, "mlx", None) # import mlx.core -> ImportError
|
||||
fake_psutil = _types.ModuleType("psutil")
|
||||
fake_psutil.virtual_memory = lambda: _types.SimpleNamespace(total = 36 * GIB)
|
||||
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
|
||||
assert LlamaCppBackend._apple_metal_memory_budget_bytes() == int(
|
||||
36 * GIB * _APPLE_UNIFIED_MEMORY_FRACTION
|
||||
)
|
||||
|
||||
def test_zero_when_no_budget_resolvable(self, monkeypatch):
|
||||
_force_apple(monkeypatch)
|
||||
monkeypatch.setitem(sys.modules, "mlx", None)
|
||||
monkeypatch.setitem(sys.modules, "psutil", None)
|
||||
assert LlamaCppBackend._apple_metal_memory_budget_bytes() == 0
|
||||
|
||||
|
||||
class TestAppleContextCap:
|
||||
"""The real ``_fit_context_to_vram`` against the reporter's M3 Pro case."""
|
||||
|
||||
def test_caps_native_context_into_unified_budget(self):
|
||||
# ~15.7 GB weights at native 262144 (~16 GB KV) -> ~32 GB on a 36 GB M3
|
||||
# Pro (~23 GB budget); the fit must reduce the context to fit.
|
||||
inst = _make_backend(native_ctx = 262144)
|
||||
inst._can_estimate_kv = lambda: True
|
||||
inst._estimate_kv_cache_bytes = (
|
||||
lambda n, *a, **k: 0 if n <= 0 else int(n * 64_000) # ~16 GB @ 262144
|
||||
)
|
||||
model_size_fit = int(15.7 * GIB)
|
||||
budget_mib = int(27 * GIB * _APPLE_UNIFIED_MEMORY_FRACTION) // (1024 * 1024)
|
||||
|
||||
# The native footprint over-commits the budget -- this is the bug.
|
||||
native_footprint_mib = (model_size_fit + inst._estimate_kv_cache_bytes(262144)) // (
|
||||
1024 * 1024
|
||||
)
|
||||
assert native_footprint_mib > budget_mib
|
||||
|
||||
capped = inst._fit_context_to_vram(
|
||||
262144, budget_mib, model_size_fit, None, budget_frac = 1.0
|
||||
)
|
||||
assert capped < 262144
|
||||
capped_footprint_mib = (model_size_fit + inst._estimate_kv_cache_bytes(capped)) // (
|
||||
1024 * 1024
|
||||
)
|
||||
assert capped_footprint_mib <= budget_mib
|
||||
|
||||
|
||||
class TestAppleBranchEndToEnd:
|
||||
"""Drive the Apple elif glue (cap / floor / explicit) via _drive, no GPU."""
|
||||
|
||||
def test_auto_context_capped_below_native(self):
|
||||
plan = _drive(
|
||||
n_ctx = 0,
|
||||
model_gib = 15.7,
|
||||
gpus = [],
|
||||
native_ctx = 262144,
|
||||
kv_per_token_bytes = 64_000,
|
||||
apple_budget_mib = 23_000, # ~22 GB: weights fit, native KV doesn't
|
||||
)
|
||||
assert 0 < plan["c_arg"] < 262144
|
||||
assert plan["use_fit"] is True # --fit on still ships as a backstop
|
||||
assert plan["gpu_indices"] is None # no CUDA device pinning on Metal
|
||||
assert plan["max_available_ctx"] == plan["c_arg"]
|
||||
|
||||
def test_floors_to_fallback_when_weights_exceed_budget(self):
|
||||
# Weights alone exceed budget: ctx can't help, so floor to 4096.
|
||||
plan = _drive(
|
||||
n_ctx = 0,
|
||||
model_gib = 100,
|
||||
gpus = [],
|
||||
native_ctx = 262144,
|
||||
apple_budget_mib = 20_000,
|
||||
)
|
||||
assert plan["c_arg"] == FALLBACK_CTX
|
||||
assert plan["use_fit"] is True
|
||||
assert plan["gpu_indices"] is None
|
||||
|
||||
def test_explicit_context_honored_verbatim(self):
|
||||
# Explicit context is never shrunk, but the UI ceiling still tightens.
|
||||
plan = _drive(
|
||||
n_ctx = 200_000,
|
||||
model_gib = 15.7,
|
||||
gpus = [],
|
||||
native_ctx = 262144,
|
||||
kv_per_token_bytes = 64_000,
|
||||
apple_budget_mib = 23_000,
|
||||
)
|
||||
assert plan["c_arg"] == 200_000 # launch context honored verbatim
|
||||
assert plan["use_fit"] is True
|
||||
# Ceiling reflects the budget so the over-budget warning still fires.
|
||||
assert plan["max_available_ctx"] < 262144
|
||||
|
||||
|
||||
class TestAppleMtpFlatReserve:
|
||||
"""Apple cap reserves the flat MTP fraction up front (like _pin_fraction) so
|
||||
an unsized MTP draft (Qwen3.6-MTP, #6529) can't over-commit."""
|
||||
|
||||
def test_flat_reserve_keeps_draft_within_budget(self):
|
||||
# No reserve -> cap fills the budget, leaving nothing for the ~5% draft.
|
||||
kw = dict(
|
||||
n_ctx = 0,
|
||||
model_gib = 15.7,
|
||||
gpus = [],
|
||||
native_ctx = 262144,
|
||||
kv_per_token_bytes = 64_000,
|
||||
apple_budget_mib = 23_000,
|
||||
)
|
||||
no_reserve = _drive(**kw, flat_mtp_reserve = 0.0)
|
||||
with_reserve = _drive(**kw, flat_mtp_reserve = 0.05)
|
||||
|
||||
def footprint_mib(ctx):
|
||||
return (15.7 * GIB + ctx * 64_000) / (1024 * 1024)
|
||||
|
||||
# No reserve: main footprint + 5% draft exceeds the budget.
|
||||
assert footprint_mib(no_reserve["c_arg"]) + 0.05 * 23_000 > 23_000
|
||||
# With reserve: the cap is smaller and the full footprint fits.
|
||||
assert with_reserve["c_arg"] < no_reserve["c_arg"]
|
||||
assert footprint_mib(with_reserve["c_arg"]) + 0.05 * 23_000 <= 23_000
|
||||
|
||||
def test_no_reserve_is_a_noop_when_mtp_absent(self):
|
||||
# flat_mtp_reserve == 0 (the common, non-MTP case) must not change the cap.
|
||||
kw = dict(
|
||||
n_ctx = 0,
|
||||
model_gib = 15.7,
|
||||
gpus = [],
|
||||
native_ctx = 262144,
|
||||
kv_per_token_bytes = 64_000,
|
||||
apple_budget_mib = 23_000,
|
||||
)
|
||||
assert _drive(**kw, flat_mtp_reserve = 0.0) == _drive(**kw)
|
||||
|
||||
|
||||
class TestAppleNoKvMetadataFloor:
|
||||
"""Sparse KV metadata floors the auto context to FALLBACK_CTX (like the
|
||||
discrete file-size-only fallback) instead of launching at native."""
|
||||
|
||||
def test_sparse_kv_floors_auto_context(self):
|
||||
plan = _drive(
|
||||
n_ctx = 0,
|
||||
model_gib = 15.7,
|
||||
gpus = [],
|
||||
native_ctx = 262144,
|
||||
can_estimate_kv = False,
|
||||
apple_budget_mib = 23_000,
|
||||
)
|
||||
assert plan["c_arg"] == FALLBACK_CTX # not native 262144
|
||||
assert plan["use_fit"] is True
|
||||
assert plan["gpu_indices"] is None
|
||||
|
||||
def test_sparse_kv_still_honors_explicit_context(self):
|
||||
plan = _drive(
|
||||
n_ctx = 100_000,
|
||||
model_gib = 15.7,
|
||||
gpus = [],
|
||||
native_ctx = 262144,
|
||||
can_estimate_kv = False,
|
||||
apple_budget_mib = 23_000,
|
||||
)
|
||||
assert plan["c_arg"] == 100_000 # explicit honored even without KV sizing
|
||||
|
|
|
|||
|
|
@ -77,6 +77,23 @@ def _tool_names(payload: dict) -> list[str]:
|
|||
]
|
||||
|
||||
|
||||
def _patch_monotonic(monkeypatch, values: list[float]) -> None:
|
||||
import core.inference.llama_cpp as llama_cpp_mod
|
||||
|
||||
it = iter(values)
|
||||
last = values[-1]
|
||||
|
||||
def fake_monotonic() -> float:
|
||||
nonlocal last
|
||||
try:
|
||||
last = next(it)
|
||||
except StopIteration:
|
||||
pass
|
||||
return last
|
||||
|
||||
monkeypatch.setattr(llama_cpp_mod.time, "monotonic", fake_monotonic)
|
||||
|
||||
|
||||
def _structured_tool_call(tool_name: str, arguments: dict, call_id: str) -> list[str]:
|
||||
return [
|
||||
_sse(
|
||||
|
|
@ -200,6 +217,80 @@ def test_structured_tool_call_after_visible_preface_is_executed(monkeypatch):
|
|||
assert assistant_messages[-1]["tool_calls"][0]["function"]["name"] == "render_html"
|
||||
|
||||
|
||||
def test_buffered_reasoning_answer_emits_backend_summary(monkeypatch):
|
||||
stream = [
|
||||
_sse({"reasoning_content": "I am thinking."}),
|
||||
_sse({"reasoning_content": " Still thinking."}),
|
||||
_sse({"content": "Final answer."}),
|
||||
_done(),
|
||||
]
|
||||
payloads: list[dict] = []
|
||||
backend = _make_backend(monkeypatch, [stream], payloads)
|
||||
_patch_monotonic(monkeypatch, [100.0, 110.0, 172.0, 172.0])
|
||||
|
||||
events = list(
|
||||
backend.generate_chat_completion_with_tools(
|
||||
messages = [{"role": "user", "content": "answer"}],
|
||||
tools = [{"type": "function", "function": {"name": "web_search"}}],
|
||||
max_tool_iterations = 1,
|
||||
)
|
||||
)
|
||||
|
||||
summary_index = next(
|
||||
i for i, event in enumerate(events) if event["type"] == "reasoning_summary"
|
||||
)
|
||||
content_index = next(i for i, event in enumerate(events) if event["type"] == "content")
|
||||
assert summary_index < content_index
|
||||
assert events[summary_index]["duration_ms"] == 62000
|
||||
assert (
|
||||
events[content_index]["text"]
|
||||
== "<think>I am thinking. Still thinking.</think>Final answer."
|
||||
)
|
||||
|
||||
|
||||
def test_consumed_tool_final_pass_emits_latest_reasoning_summary(monkeypatch):
|
||||
tool_stream = [
|
||||
_sse({"reasoning_content": "Need a render."}),
|
||||
_sse(
|
||||
{
|
||||
"content": '<tool_call>{"name":"render_html","arguments":{"code":"<html>ok</html>"}}</tool_call>'
|
||||
}
|
||||
),
|
||||
_done(),
|
||||
]
|
||||
final_stream = [
|
||||
_sse({"reasoning_content": "Now synthesize."}),
|
||||
_sse({"content": "Final from tool."}),
|
||||
_done(),
|
||||
]
|
||||
payloads: list[dict] = []
|
||||
backend = _make_backend(monkeypatch, [tool_stream, final_stream], payloads)
|
||||
_patch_monotonic(monkeypatch, [200.0, 201.0, 203.0, 300.0, 400.0, 405.0, 405.0])
|
||||
|
||||
def fake_execute_tool(name, arguments, **_kwargs):
|
||||
return "Rendered HTML canvas: Done."
|
||||
|
||||
monkeypatch.setattr("core.inference.tools.execute_tool", fake_execute_tool)
|
||||
|
||||
events = list(
|
||||
backend.generate_chat_completion_with_tools(
|
||||
messages = [{"role": "user", "content": "render then answer"}],
|
||||
tools = [{"type": "function", "function": {"name": "render_html"}}],
|
||||
max_tool_iterations = 1,
|
||||
)
|
||||
)
|
||||
|
||||
summaries = [event for event in events if event["type"] == "reasoning_summary"]
|
||||
assert [event["duration_ms"] for event in summaries] == [2000, 5000]
|
||||
final_summary_index = events.index(summaries[-1])
|
||||
final_content_index = next(
|
||||
i
|
||||
for i, event in enumerate(events)
|
||||
if event.get("type") == "content" and "Final from tool." in event.get("text", "")
|
||||
)
|
||||
assert final_summary_index < final_content_index
|
||||
|
||||
|
||||
def test_repeat_render_html_nudge_is_not_user_visible_error(monkeypatch):
|
||||
"""A repeated render_html call is an internal no-op, not a visible card."""
|
||||
|
||||
|
|
@ -1645,3 +1736,80 @@ def test_empty_tool_call_id_does_not_emit_provisional_card(monkeypatch):
|
|||
assert provisional == []
|
||||
# The real call still executes despite the missing id.
|
||||
assert calls == [("python", {"code": big_code})]
|
||||
|
||||
|
||||
def _usage_done(usage: dict, finish_reason: str = "stop") -> str:
|
||||
"""A terminal SSE chunk carrying llama-server's ``usage`` block, the way the
|
||||
real server reports it on the final chunk of a completion."""
|
||||
return (
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}],
|
||||
"usage": usage,
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
def test_metadata_event_preserves_prompt_tokens_details(monkeypatch):
|
||||
"""The tool loop's metadata event must carry llama-server's
|
||||
``prompt_tokens_details`` (KV-cache hits) through ``_build_metadata_event``,
|
||||
so the route reports real ``cached_tokens`` instead of always 0 (#6570).
|
||||
|
||||
This drives the *real* generator; the route-level test feeds a pre-built
|
||||
metadata event and so never exercises this code.
|
||||
"""
|
||||
stream = [
|
||||
_sse({"content": "The answer is 42."}),
|
||||
_usage_done(
|
||||
{
|
||||
"prompt_tokens": 20,
|
||||
"completion_tokens": 4,
|
||||
"prompt_tokens_details": {"cached_tokens": 16},
|
||||
}
|
||||
),
|
||||
_done(),
|
||||
]
|
||||
payloads: list[dict] = []
|
||||
backend = _make_backend(monkeypatch, [stream], payloads)
|
||||
|
||||
events = list(
|
||||
backend.generate_chat_completion_with_tools(
|
||||
messages = [{"role": "user", "content": "hi"}],
|
||||
tools = [],
|
||||
max_tool_iterations = 1,
|
||||
)
|
||||
)
|
||||
|
||||
metadata = [e for e in events if e.get("type") == "metadata"]
|
||||
assert metadata, "expected a metadata event"
|
||||
usage = metadata[-1]["usage"]
|
||||
assert usage["prompt_tokens_details"] == {"cached_tokens": 16}
|
||||
assert usage["prompt_tokens"] == 20
|
||||
assert usage["completion_tokens"] == 4
|
||||
|
||||
|
||||
def test_metadata_event_omits_prompt_tokens_details_when_absent(monkeypatch):
|
||||
"""No KV-cache block from the server -> the key isn't fabricated, so the
|
||||
route falls back to its 0-default instead of reading a bogus value."""
|
||||
stream = [
|
||||
_sse({"content": "hi"}),
|
||||
_usage_done({"prompt_tokens": 5, "completion_tokens": 2}),
|
||||
_done(),
|
||||
]
|
||||
payloads: list[dict] = []
|
||||
backend = _make_backend(monkeypatch, [stream], payloads)
|
||||
|
||||
events = list(
|
||||
backend.generate_chat_completion_with_tools(
|
||||
messages = [{"role": "user", "content": "hi"}],
|
||||
tools = [],
|
||||
max_tool_iterations = 1,
|
||||
)
|
||||
)
|
||||
|
||||
metadata = [e for e in events if e.get("type") == "metadata"]
|
||||
assert metadata, "expected a metadata event"
|
||||
assert "prompt_tokens_details" not in metadata[-1]["usage"]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import asyncio
|
|||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
_backend = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
|
@ -40,6 +41,123 @@ def test_stream_first_item_deadline_after_headers():
|
|||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_stream_first_item_deadline_does_not_hop_tasks():
|
||||
async def _run():
|
||||
outer_task = asyncio.current_task()
|
||||
seen_tasks = []
|
||||
|
||||
class _One:
|
||||
def __init__(self):
|
||||
self.done = False
|
||||
|
||||
async def __anext__(self):
|
||||
seen_tasks.append(asyncio.current_task())
|
||||
if self.done:
|
||||
raise StopAsyncIteration
|
||||
self.done = True
|
||||
return "data: {}"
|
||||
|
||||
out = []
|
||||
async for item in inf_mod._aiter_llama_stream_items(
|
||||
_One(),
|
||||
first_token_deadline = time.monotonic() + 1,
|
||||
):
|
||||
out.append(item)
|
||||
|
||||
assert out == ["data: {}"]
|
||||
assert seen_tasks == [outer_task, outer_task]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_stream_first_item_deadline_uses_compat_timeout_without_task_hop(monkeypatch):
|
||||
monkeypatch.setattr(inf_mod.asyncio, "timeout", None, raising = False)
|
||||
|
||||
async def _run():
|
||||
outer_task = asyncio.current_task()
|
||||
seen_tasks = []
|
||||
|
||||
class _One:
|
||||
def __init__(self):
|
||||
self.done = False
|
||||
|
||||
async def __anext__(self):
|
||||
seen_tasks.append(asyncio.current_task())
|
||||
if self.done:
|
||||
raise StopAsyncIteration
|
||||
self.done = True
|
||||
return "data: {}"
|
||||
|
||||
out = []
|
||||
async for item in inf_mod._aiter_llama_stream_items(
|
||||
_One(),
|
||||
first_token_deadline = time.monotonic() + 1,
|
||||
):
|
||||
out.append(item)
|
||||
|
||||
assert out == ["data: {}"]
|
||||
assert seen_tasks == [outer_task, outer_task]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_stream_wait_stops_on_known_disconnect_before_read():
|
||||
async def _run():
|
||||
state = SimpleNamespace(disconnect_checks = 0)
|
||||
cancel_event = threading.Event()
|
||||
|
||||
class _Request:
|
||||
async def is_disconnected(self):
|
||||
state.disconnect_checks += 1
|
||||
return True
|
||||
|
||||
class _Unread:
|
||||
async def __anext__(self):
|
||||
raise AssertionError("stream should stop before reading upstream")
|
||||
|
||||
async for _ in inf_mod._aiter_llama_stream_items(
|
||||
_Unread(),
|
||||
cancel_event = cancel_event,
|
||||
request = _Request(),
|
||||
first_token_deadline = time.monotonic() + 1,
|
||||
):
|
||||
raise AssertionError("stream should stop after disconnect")
|
||||
|
||||
assert cancel_event.is_set()
|
||||
assert state.disconnect_checks == 1
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_stream_wait_does_not_shorten_upstream_read_for_disconnect_poll():
|
||||
async def _run():
|
||||
response = SimpleNamespace(request = SimpleNamespace(extensions = {"timeout": {}}))
|
||||
seen_read_timeouts = []
|
||||
|
||||
class _Request:
|
||||
async def is_disconnected(self):
|
||||
return False
|
||||
|
||||
class _NoItem:
|
||||
async def __anext__(self):
|
||||
seen_read_timeouts.append(response.request.extensions["timeout"]["read"])
|
||||
raise StopAsyncIteration
|
||||
|
||||
async for _ in inf_mod._aiter_llama_stream_items(
|
||||
_NoItem(),
|
||||
cancel_event = threading.Event(),
|
||||
request = _Request(),
|
||||
response = response,
|
||||
first_token_deadline = time.monotonic() + 1,
|
||||
):
|
||||
raise AssertionError("stream should end")
|
||||
|
||||
assert seen_read_timeouts
|
||||
assert seen_read_timeouts[0] > inf_mod._STREAM_DISCONNECT_POLL_TIMEOUT_S
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_preheader_send_cleanup_on_disconnect_and_cancel():
|
||||
async def _run(cancel_parent):
|
||||
state = SimpleNamespace(disconnected = False, closed = False, cancelled = False)
|
||||
|
|
|
|||
|
|
@ -423,6 +423,46 @@ def test_tool_healing_strip_handles_hyphenated_function_names():
|
|||
assert out == "before after"
|
||||
|
||||
|
||||
def test_tool_healing_strip_handles_gemma_native_tool_call():
|
||||
from core.tool_healing import strip_tool_call_markup
|
||||
out = strip_tool_call_markup(
|
||||
'before <|tool_call>call:mcp__srv__list-issues{repo:"octocat/hello"}<tool_call|> after'
|
||||
)
|
||||
assert out == "before after"
|
||||
|
||||
|
||||
def test_tool_healing_strip_handles_gemma_close_only_marker():
|
||||
from core.tool_healing import strip_tool_call_markup
|
||||
assert strip_tool_call_markup("before <tool_call|> after") == "before after"
|
||||
assert strip_tool_call_markup("before <tool_call|> after", final = True) == "before after"
|
||||
|
||||
|
||||
def test_tool_healing_parser_handles_gemma_native_windows_path():
|
||||
from core.tool_healing import parse_tool_calls_from_text
|
||||
import json as _json
|
||||
|
||||
calls = parse_tool_calls_from_text(
|
||||
r'<|tool_call>call:ls{path:<|"|>C:\Users\wasim\repo<|"|>}<tool_call|>'
|
||||
)
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["function"]["name"] == "ls"
|
||||
assert _json.loads(calls[0]["function"]["arguments"]) == {"path": r"C:\Users\wasim\repo"}
|
||||
|
||||
|
||||
def test_tool_healing_json_parser_preserves_literal_gemma_quote_token():
|
||||
from core.tool_healing import parse_tool_calls_from_text
|
||||
import json as _json
|
||||
|
||||
text = (
|
||||
"<tool_call>"
|
||||
+ _json.dumps({"name": "python", "arguments": {"code": "print('<|\"|>')"}})
|
||||
+ "</tool_call>"
|
||||
)
|
||||
calls = parse_tool_calls_from_text(text, allow_incomplete = False)
|
||||
assert len(calls) == 1
|
||||
assert _json.loads(calls[0]["function"]["arguments"]) == {"code": "print('<|\"|>')"}
|
||||
|
||||
|
||||
def test_gguf_allow_list_blocks_unadvertised_tool(monkeypatch):
|
||||
"""A tool call not in the per-request list must be refused by the GGUF
|
||||
agentic loop (mirroring the safetensors path)."""
|
||||
|
|
|
|||
100
studio/backend/tests/test_message_content.py
Normal file
100
studio/backend/tests/test_message_content.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. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Tests for `content_to_text`, the #4383 fix for list-form message content.
|
||||
|
||||
Loaded by file path so the test skips importing ``core.inference`` (whose
|
||||
``__init__`` pulls in the orchestrator + llama_cpp / torch).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_BACKEND_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _load_message_content():
|
||||
path = _BACKEND_DIR / "core/inference/message_content.py"
|
||||
spec = importlib.util.spec_from_file_location("message_content_under_test", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_string_is_returned_unchanged():
|
||||
mc = _load_message_content()
|
||||
assert mc.content_to_text("hello world") == "hello world"
|
||||
assert mc.content_to_text("") == ""
|
||||
|
||||
|
||||
def test_none_becomes_empty_string():
|
||||
mc = _load_message_content()
|
||||
assert mc.content_to_text(None) == ""
|
||||
|
||||
|
||||
def test_single_text_part_list():
|
||||
mc = _load_message_content()
|
||||
content = [{"type": "text", "text": "hello"}]
|
||||
assert mc.content_to_text(content) == "hello"
|
||||
|
||||
|
||||
def test_multimodal_list_drops_non_text_parts():
|
||||
mc = _load_message_content()
|
||||
content = [
|
||||
{"type": "text", "text": "describe this"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
|
||||
]
|
||||
assert mc.content_to_text(content) == "describe this"
|
||||
|
||||
|
||||
def test_multiple_text_parts_joined_with_newline():
|
||||
mc = _load_message_content()
|
||||
content = [
|
||||
{"type": "text", "text": "first"},
|
||||
{"type": "text", "text": "second"},
|
||||
]
|
||||
assert mc.content_to_text(content) == "first\nsecond"
|
||||
|
||||
|
||||
def test_bare_string_items_in_list():
|
||||
mc = _load_message_content()
|
||||
assert mc.content_to_text(["a", "b"]) == "a\nb"
|
||||
|
||||
|
||||
def test_audio_and_image_only_list_is_empty():
|
||||
mc = _load_message_content()
|
||||
content = [
|
||||
{"type": "image_url", "image_url": {"url": "x"}},
|
||||
{"type": "input_audio", "input_audio": {"data": "y", "format": "wav"}},
|
||||
]
|
||||
assert mc.content_to_text(content) == ""
|
||||
|
||||
|
||||
def test_part_without_type_treated_as_text():
|
||||
mc = _load_message_content()
|
||||
# A ``text`` field with no ``type`` is treated as text.
|
||||
assert mc.content_to_text([{"text": "untyped"}]) == "untyped"
|
||||
|
||||
|
||||
def test_empty_text_parts_skipped():
|
||||
mc = _load_message_content()
|
||||
content = [
|
||||
{"type": "text", "text": ""},
|
||||
{"type": "text", "text": "kept"},
|
||||
]
|
||||
assert mc.content_to_text(content) == "kept"
|
||||
|
||||
|
||||
def test_tuple_behaves_like_list():
|
||||
mc = _load_message_content()
|
||||
content = ({"type": "text", "text": "x"}, {"type": "text", "text": "y"})
|
||||
assert mc.content_to_text(content) == "x\ny"
|
||||
|
||||
|
||||
def test_result_supports_string_ops():
|
||||
mc = _load_message_content()
|
||||
# Crux of #4383: result must be a plain str for caller .strip()/.replace().
|
||||
out = mc.content_to_text([{"type": "text", "text": " padded "}])
|
||||
assert out.strip() == "padded"
|
||||
assert isinstance(out, str)
|
||||
|
|
@ -119,6 +119,68 @@ def test_repair_install_pins_transformers_and_cleans_up(monkeypatch):
|
|||
assert env.get("UV_OVERRIDE", "").endswith("overrides-darwin-arm64.txt")
|
||||
|
||||
|
||||
def test_install_requires_prebuilt_wheels(monkeypatch):
|
||||
# A source distribution's PEP 517 build backend runs arbitrary code at install
|
||||
# time, before the post-install stack check. The unattended self-heal must
|
||||
# require pre-built wheels so a malicious resolver-selected sdist cannot execute
|
||||
# during ordinary Studio startup. mlx/mlx-metal ship wheels only and
|
||||
# mlx-lm/mlx-vlm publish py3-none-any wheels, so a healthy self-heal still works.
|
||||
pytest.importorskip("transformers")
|
||||
captured = {}
|
||||
|
||||
class _Result:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
|
||||
monkeypatch.setattr(mr, "_uv_executable", lambda: "/usr/bin/uv")
|
||||
monkeypatch.setattr(
|
||||
mr.subprocess, "run", lambda cmd, **k: captured.update(cmd = cmd) or _Result()
|
||||
)
|
||||
monkeypatch.setattr(mr, "mlx_stack_available", lambda: True)
|
||||
|
||||
assert mr.attempt_mlx_repair() is True
|
||||
assert mr._ONLY_BINARY_ARG in captured["cmd"]
|
||||
|
||||
|
||||
def test_install_env_drops_secrets_and_source_redirects(monkeypatch):
|
||||
# The unattended self-heal must not hand resolver/build code the full Studio
|
||||
# environment: secrets and package-source redirects are dropped, while the
|
||||
# variables uv genuinely needs are forwarded.
|
||||
monkeypatch.setenv("HF_TOKEN", "secret-hf")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "secret-aws")
|
||||
monkeypatch.setenv("WANDB_API_KEY", "secret-wandb")
|
||||
monkeypatch.setenv("UV_FIND_LINKS", "/tmp/evil")
|
||||
monkeypatch.setenv("UV_DEFAULT_INDEX", "file:///tmp/evil-index")
|
||||
monkeypatch.setenv("UV_INDEX_URL", "https://evil.example/simple")
|
||||
monkeypatch.setenv("PIP_INDEX_URL", "https://evil.example/simple")
|
||||
monkeypatch.setenv("UV_CACHE_DIR", "/tmp/evil-cache")
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", "/tmp/evil-xdg-cache")
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin")
|
||||
monkeypatch.setenv("HOME", "/home/studio")
|
||||
|
||||
env = mr._mlx_install_env()
|
||||
|
||||
# Secrets never reach a (potentially malicious) build/install hook.
|
||||
for secret in ("HF_TOKEN", "AWS_SECRET_ACCESS_KEY", "WANDB_API_KEY"):
|
||||
assert secret not in env
|
||||
# A poisoned process env cannot repoint the install at a hostile source or
|
||||
# an attacker-staged cache (cache poisoning / symlink writes).
|
||||
for redirect in (
|
||||
"UV_FIND_LINKS",
|
||||
"UV_DEFAULT_INDEX",
|
||||
"UV_INDEX_URL",
|
||||
"PIP_INDEX_URL",
|
||||
"UV_CACHE_DIR",
|
||||
"XDG_CACHE_HOME",
|
||||
):
|
||||
assert redirect not in env
|
||||
# What uv genuinely needs is still forwarded.
|
||||
assert env["PATH"] == "/usr/bin:/bin"
|
||||
assert env["HOME"] == "/home/studio"
|
||||
# UV_OVERRIDE is set by us (not inherited), so a poisoned one is ignored.
|
||||
assert env.get("UV_OVERRIDE", "").endswith("overrides-darwin-arm64.txt")
|
||||
|
||||
|
||||
def test_repair_rejects_inadequate_stack(monkeypatch):
|
||||
# A successful uv run that still leaves an old/missing mlx-vlm must NOT clear
|
||||
# chat-only: attempt_mlx_repair returns False so Train/Export stay disabled.
|
||||
|
|
@ -276,3 +338,22 @@ def test_attempts_only_once_per_process(monkeypatch):
|
|||
second = mr.start_mlx_autorepair_if_needed()
|
||||
assert first is True
|
||||
assert second is False # guard prevents a second concurrent attempt
|
||||
|
||||
|
||||
def test_mlx_install_env_routes_uv_override_through_safe_path(monkeypatch):
|
||||
# uv truncates UV_OVERRIDE at the first space (issue #6503).
|
||||
seen = {}
|
||||
|
||||
def _spy(path):
|
||||
seen["path"] = path
|
||||
return "/space free/marker.txt".replace(" ", "_")
|
||||
|
||||
monkeypatch.setattr(mr, "uv_safe_path", _spy)
|
||||
monkeypatch.delenv("UV_OVERRIDE", raising = False)
|
||||
|
||||
env = mr._mlx_install_env()
|
||||
|
||||
# The override file ships in the repo, so the helper must have run.
|
||||
assert "path" in seen
|
||||
assert str(seen["path"]).endswith("overrides-darwin-arm64.txt")
|
||||
assert env["UV_OVERRIDE"] == "/space_free/marker.txt"
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ from routes.inference import (
|
|||
_openai_passthrough_stream,
|
||||
_openai_stream_usage_chunk,
|
||||
_proxy_to_external_provider,
|
||||
_SameTaskStreamingResponse,
|
||||
_set_or_prepend_system_message,
|
||||
openai_completions,
|
||||
openai_embeddings,
|
||||
|
|
@ -1245,6 +1246,79 @@ class TestGgufVisionToolRouting:
|
|||
|
||||
return TestGgufVisionToolRouting._drive(_consume())
|
||||
|
||||
@staticmethod
|
||||
def _sse_payloads(chunks):
|
||||
payloads = []
|
||||
for chunk in chunks:
|
||||
if isinstance(chunk, bytes):
|
||||
chunk = chunk.decode()
|
||||
for line in str(chunk).splitlines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
data = line.removeprefix("data: ")
|
||||
if data == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
payloads.append(json.loads(data))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return payloads
|
||||
|
||||
def _run_gguf_case(
|
||||
self,
|
||||
monkeypatch,
|
||||
*,
|
||||
generate = None,
|
||||
tool_generate = None,
|
||||
payload_kwargs = None,
|
||||
backend_kwargs = None,
|
||||
):
|
||||
import routes.inference as inf_mod
|
||||
|
||||
reset_tool_policy()
|
||||
|
||||
def _plain(**_kwargs):
|
||||
raise AssertionError("plain GGUF path should not be used")
|
||||
|
||||
backend_data = {
|
||||
"is_loaded": True,
|
||||
"is_vision": False,
|
||||
"supports_tools": tool_generate is not None,
|
||||
"supports_reasoning": True,
|
||||
"reasoning_always_on": True,
|
||||
"_is_audio": False,
|
||||
"model_identifier": "test-gguf",
|
||||
"context_length": 4096,
|
||||
"generate_chat_completion": generate or _plain,
|
||||
}
|
||||
if tool_generate is not None:
|
||||
backend_data["generate_chat_completion_with_tools"] = tool_generate
|
||||
if backend_kwargs:
|
||||
backend_data.update(backend_kwargs)
|
||||
backend = SimpleNamespace(**backend_data)
|
||||
|
||||
monitor = ApiMonitor(max_entries = 3)
|
||||
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
|
||||
monkeypatch.setattr(inf_mod, "get_llama_cpp_backend", lambda: backend)
|
||||
|
||||
request_data = {
|
||||
"model": "default",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
}
|
||||
if payload_kwargs:
|
||||
request_data.update(payload_kwargs)
|
||||
payload = ChatCompletionRequest(**request_data)
|
||||
response = self._drive(
|
||||
openai_chat_completions(payload, request = self._Request(), current_subject = "test")
|
||||
)
|
||||
result = SimpleNamespace(response = response, monitor = monitor, backend = backend)
|
||||
if request_data.get("stream"):
|
||||
result.chunks = self._consume_response(response)
|
||||
result.payloads = self._sse_payloads(result.chunks)
|
||||
else:
|
||||
result.body = json.loads(response.body)
|
||||
return result
|
||||
|
||||
def test_image_request_with_enabled_tools_enters_gguf_tool_loop(self, monkeypatch):
|
||||
import routes.inference as inf_mod
|
||||
|
||||
|
|
@ -1275,6 +1349,7 @@ class TestGgufVisionToolRouting:
|
|||
model = "default",
|
||||
enable_tools = True,
|
||||
enabled_tools = ["web_search"],
|
||||
stream = True,
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
|
|
@ -1334,6 +1409,7 @@ class TestGgufVisionToolRouting:
|
|||
enable_tools = True,
|
||||
enabled_tools = ["web_search"],
|
||||
parallel_tool_calls = False,
|
||||
stream = True,
|
||||
messages = [{"role": "user", "content": "search once"}],
|
||||
)
|
||||
|
||||
|
|
@ -1390,6 +1466,152 @@ class TestGgufVisionToolRouting:
|
|||
assert "confirm_tool_calls requires stream=true" in entry["error"]
|
||||
assert monitor.active_count() == 0
|
||||
|
||||
def test_standard_gguf_stream_splits_reasoning_content(self, monkeypatch):
|
||||
def _generate(**_kwargs):
|
||||
yield "<thi"
|
||||
yield "<think>plan"
|
||||
yield "<think>plan</think>vis"
|
||||
yield "<think>plan</think>visible"
|
||||
yield {
|
||||
"type": "metadata",
|
||||
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
|
||||
result = self._run_gguf_case(
|
||||
monkeypatch,
|
||||
generate = _generate,
|
||||
payload_kwargs = {"stream": True},
|
||||
)
|
||||
deltas = [p["choices"][0].get("delta", {}) for p in result.payloads if p.get("choices")]
|
||||
|
||||
assert "".join(d.get("reasoning_content", "") for d in deltas) == "plan"
|
||||
assert "".join(d.get("content", "") for d in deltas) == "visible"
|
||||
assert all("<think>" not in d.get("content", "") for d in deltas)
|
||||
[entry] = result.monitor.snapshot()
|
||||
assert entry["reply"] == "visible"
|
||||
|
||||
def test_reasoning_capable_gguf_stream_splits_reasoning_by_default(self, monkeypatch):
|
||||
def _generate(**_kwargs):
|
||||
yield "<think>plan</think>visible"
|
||||
yield {
|
||||
"type": "metadata",
|
||||
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
|
||||
result = self._run_gguf_case(
|
||||
monkeypatch,
|
||||
generate = _generate,
|
||||
payload_kwargs = {"stream": True},
|
||||
backend_kwargs = {"reasoning_always_on": False},
|
||||
)
|
||||
deltas = [p["choices"][0].get("delta", {}) for p in result.payloads if p.get("choices")]
|
||||
|
||||
assert "".join(d.get("reasoning_content", "") for d in deltas) == "plan"
|
||||
assert "".join(d.get("content", "") for d in deltas) == "visible"
|
||||
[entry] = result.monitor.snapshot()
|
||||
assert entry["reply"] == "visible"
|
||||
|
||||
def test_reasoning_capable_gguf_stream_sanitizes_think_tags_when_disabled(self, monkeypatch):
|
||||
def _generate(**_kwargs):
|
||||
yield "<think>leaked</think>visible"
|
||||
yield {
|
||||
"type": "metadata",
|
||||
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
|
||||
result = self._run_gguf_case(
|
||||
monkeypatch,
|
||||
generate = _generate,
|
||||
payload_kwargs = {"stream": True, "enable_thinking": False},
|
||||
backend_kwargs = {"reasoning_always_on": False},
|
||||
)
|
||||
deltas = [p["choices"][0].get("delta", {}) for p in result.payloads if p.get("choices")]
|
||||
|
||||
assert "".join(d.get("reasoning_content", "") for d in deltas) == "leaked"
|
||||
assert "".join(d.get("content", "") for d in deltas) == "visible"
|
||||
assert all("<think>" not in d.get("content", "") for d in deltas)
|
||||
[entry] = result.monitor.snapshot()
|
||||
assert entry["reply"] == "visible"
|
||||
|
||||
def test_gguf_tool_stream_splits_reasoning_and_strips_gemma_tool_marker(self, monkeypatch):
|
||||
def _tools(**_kwargs):
|
||||
yield {
|
||||
"type": "content",
|
||||
"text": '<think>plan</think>visible <|tool_call>call:terminal{command:"ls"}<tool_call|>',
|
||||
}
|
||||
yield {
|
||||
"type": "metadata",
|
||||
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
|
||||
result = self._run_gguf_case(
|
||||
monkeypatch,
|
||||
tool_generate = _tools,
|
||||
payload_kwargs = {
|
||||
"stream": True,
|
||||
"enable_tools": True,
|
||||
"enabled_tools": ["terminal"],
|
||||
"messages": [{"role": "user", "content": "list files"}],
|
||||
},
|
||||
)
|
||||
deltas = [p["choices"][0].get("delta", {}) for p in result.payloads if p.get("choices")]
|
||||
|
||||
assert "".join(d.get("reasoning_content", "") for d in deltas) == "plan"
|
||||
combined_content = "".join(d.get("content", "") for d in deltas)
|
||||
assert combined_content == "visible "
|
||||
assert "<|tool_call>" not in combined_content
|
||||
[entry] = result.monitor.snapshot()
|
||||
assert entry["reply"] == "visible "
|
||||
|
||||
def test_gguf_tool_stream_flushes_held_text_before_status_reset(self, monkeypatch):
|
||||
def _tools(**_kwargs):
|
||||
yield {"type": "content", "text": "answer <"}
|
||||
yield {"type": "status", "text": ""}
|
||||
yield {
|
||||
"type": "metadata",
|
||||
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
|
||||
result = self._run_gguf_case(
|
||||
monkeypatch,
|
||||
tool_generate = _tools,
|
||||
payload_kwargs = {
|
||||
"stream": True,
|
||||
"enable_tools": True,
|
||||
"enabled_tools": ["terminal"],
|
||||
"messages": [{"role": "user", "content": "say literal"}],
|
||||
},
|
||||
)
|
||||
deltas = [p["choices"][0].get("delta", {}) for p in result.payloads if p.get("choices")]
|
||||
|
||||
combined_content = "".join(d.get("content", "") for d in deltas)
|
||||
assert combined_content == "answer <"
|
||||
[entry] = result.monitor.snapshot()
|
||||
assert entry["reply"] == "answer <"
|
||||
|
||||
def test_non_streaming_gguf_splits_reasoning_content(self, monkeypatch):
|
||||
def _generate(**_kwargs):
|
||||
yield "<think>plan</think>visible"
|
||||
yield {
|
||||
"type": "metadata",
|
||||
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
|
||||
result = self._run_gguf_case(monkeypatch, generate = _generate)
|
||||
body = result.body
|
||||
message = body["choices"][0]["message"]
|
||||
|
||||
assert message["content"] == "visible"
|
||||
assert message["reasoning_content"] == "plan"
|
||||
[entry] = result.monitor.snapshot()
|
||||
assert entry["reply"] == "visible"
|
||||
|
||||
def test_non_streaming_gguf_n_records_all_monitor_replies(self, monkeypatch):
|
||||
import routes.inference as inf_mod
|
||||
|
||||
|
|
@ -1552,6 +1774,61 @@ class TestApiMonitorProviderAndCompletionStreams:
|
|||
async def is_disconnected(self):
|
||||
return False
|
||||
|
||||
async def _run_passthrough_stream(self, monkeypatch, lines):
|
||||
import routes.inference as inf_mod
|
||||
|
||||
class Request:
|
||||
async def is_disconnected(self):
|
||||
return False
|
||||
|
||||
async def fake_send(*_args, **_kwargs):
|
||||
return httpx.Response(200, content = b"")
|
||||
|
||||
async def fake_items(*_args, **_kwargs):
|
||||
for line in lines:
|
||||
yield line
|
||||
|
||||
monitor = ApiMonitor(max_entries = 3)
|
||||
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
|
||||
monkeypatch.setattr(inf_mod, "_send_stream_with_preheader_cancel", fake_send)
|
||||
monkeypatch.setattr(inf_mod, "_aiter_llama_stream_items", fake_items)
|
||||
monitor_id = monitor.start(
|
||||
endpoint = "/v1/chat/completions",
|
||||
method = "POST",
|
||||
model = "gguf",
|
||||
prompt = "hi",
|
||||
)
|
||||
payload = ChatCompletionRequest(
|
||||
model = "default",
|
||||
messages = [ChatMessage(role = "user", content = "hi")],
|
||||
stream = True,
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
response = await _openai_passthrough_stream(
|
||||
Request(),
|
||||
threading.Event(),
|
||||
SimpleNamespace(
|
||||
base_url = "http://llama.test",
|
||||
context_length = 4096,
|
||||
_request_reasoning_kwargs = lambda *_args, **_kwargs: None,
|
||||
),
|
||||
payload,
|
||||
"gguf",
|
||||
"chatcmpl-test",
|
||||
monitor_id = monitor_id,
|
||||
)
|
||||
chunks = [chunk async for chunk in response.body_iterator]
|
||||
return SimpleNamespace(chunks = chunks, body = "".join(chunks), monitor = monitor)
|
||||
|
||||
def test_external_non_streaming_json_updates_monitor(self, monkeypatch):
|
||||
async def _run():
|
||||
import routes.inference as inf_mod
|
||||
|
|
@ -1980,6 +2257,7 @@ class TestApiMonitorProviderAndCompletionStreams:
|
|||
"chatcmpl-test",
|
||||
monitor_id = monitor_id,
|
||||
)
|
||||
assert isinstance(response, _SameTaskStreamingResponse)
|
||||
iterator = response.body_iterator
|
||||
first = await anext(iterator)
|
||||
assert "hello" in first
|
||||
|
|
@ -1997,6 +2275,88 @@ class TestApiMonitorProviderAndCompletionStreams:
|
|||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_passthrough_stream_synthesizes_missing_finish_reason(self, monkeypatch):
|
||||
async def _run():
|
||||
result = await self._run_passthrough_stream(
|
||||
monkeypatch,
|
||||
[
|
||||
(
|
||||
'data: {"id":"upstream","created":123,"model":"gguf",'
|
||||
'"choices":[{"index":0,"delta":{"content":"hello"}}]}'
|
||||
),
|
||||
"data: [DONE]",
|
||||
],
|
||||
)
|
||||
body = result.body
|
||||
|
||||
assert '"finish_reason":"stop"' in body.replace(" ", "")
|
||||
assert "data: [DONE]" in body
|
||||
assert result.monitor.active_count() == 0
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_passthrough_stream_synthesizes_tool_call_finish_reason(self, monkeypatch):
|
||||
async def _run():
|
||||
result = await self._run_passthrough_stream(
|
||||
monkeypatch,
|
||||
[
|
||||
(
|
||||
'data: {"id":"upstream","created":123,"model":"gguf",'
|
||||
'"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,'
|
||||
'"id":"call_1","type":"function","function":{"name":"lookup",'
|
||||
'"arguments":"{}"}}]}}]}'
|
||||
),
|
||||
"data: [DONE]",
|
||||
],
|
||||
)
|
||||
compact = result.body.replace(" ", "")
|
||||
|
||||
assert '"finish_reason":"tool_calls"' in compact
|
||||
assert '"finish_reason":"stop"' not in compact
|
||||
assert "data: [DONE]" in result.body
|
||||
assert result.monitor.active_count() == 0
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_passthrough_stream_error_done_skips_synthetic_finish_reason(self, monkeypatch):
|
||||
async def _run():
|
||||
result = await self._run_passthrough_stream(
|
||||
monkeypatch,
|
||||
[
|
||||
'data: {"error":{"message":"boom","type":"server_error"}}',
|
||||
"data: [DONE]",
|
||||
],
|
||||
)
|
||||
compact = result.body.replace(" ", "")
|
||||
|
||||
assert '"error":{"message":"boom","type":"server_error"}' in compact
|
||||
assert '"finish_reason"' not in compact
|
||||
assert "data: [DONE]" in result.body
|
||||
[entry] = result.monitor.snapshot()
|
||||
assert entry["status"] == "error"
|
||||
assert entry["error"] == "boom"
|
||||
assert result.monitor.active_count() == 0
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_passthrough_stream_error_eof_skips_synthetic_finish_reason(self, monkeypatch):
|
||||
async def _run():
|
||||
result = await self._run_passthrough_stream(
|
||||
monkeypatch,
|
||||
['data: {"error":{"message":"boom","type":"server_error"}}'],
|
||||
)
|
||||
compact = result.body.replace(" ", "")
|
||||
|
||||
assert '"error":{"message":"boom","type":"server_error"}' in compact
|
||||
assert '"finish_reason"' not in compact
|
||||
assert "data: [DONE]" not in result.body
|
||||
[entry] = result.monitor.snapshot()
|
||||
assert entry["status"] == "error"
|
||||
assert entry["error"] == "boom"
|
||||
assert result.monitor.active_count() == 0
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_passthrough_non_streaming_cancel_finalizes_monitor(self, monkeypatch):
|
||||
async def _run():
|
||||
import routes.inference as inf_mod
|
||||
|
|
@ -2058,65 +2418,20 @@ class TestApiMonitorProviderAndCompletionStreams:
|
|||
|
||||
def test_passthrough_clean_eof_finalizes_monitor(self, monkeypatch):
|
||||
async def _run():
|
||||
import routes.inference as inf_mod
|
||||
|
||||
class Request:
|
||||
async def is_disconnected(self):
|
||||
return False
|
||||
|
||||
async def fake_send(*_args, **_kwargs):
|
||||
return httpx.Response(200, content = b"")
|
||||
|
||||
async def fake_items(*_args, **_kwargs):
|
||||
yield 'data: {"choices":[{"delta":{"content":"hello"}}]}'
|
||||
|
||||
monitor = ApiMonitor(max_entries = 3)
|
||||
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
|
||||
monkeypatch.setattr(inf_mod, "_send_stream_with_preheader_cancel", fake_send)
|
||||
monkeypatch.setattr(inf_mod, "_aiter_llama_stream_items", fake_items)
|
||||
monitor_id = monitor.start(
|
||||
endpoint = "/v1/chat/completions",
|
||||
method = "POST",
|
||||
model = "gguf",
|
||||
prompt = "hi",
|
||||
)
|
||||
payload = ChatCompletionRequest(
|
||||
model = "default",
|
||||
messages = [ChatMessage(role = "user", content = "hi")],
|
||||
stream = True,
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
],
|
||||
result = await self._run_passthrough_stream(
|
||||
monkeypatch,
|
||||
['data: {"choices":[{"delta":{"content":"hello"}}]}'],
|
||||
)
|
||||
chunks = result.chunks
|
||||
|
||||
response = await _openai_passthrough_stream(
|
||||
Request(),
|
||||
threading.Event(),
|
||||
SimpleNamespace(
|
||||
base_url = "http://llama.test",
|
||||
context_length = 4096,
|
||||
_request_reasoning_kwargs = lambda *_args, **_kwargs: None,
|
||||
),
|
||||
payload,
|
||||
"gguf",
|
||||
"chatcmpl-test",
|
||||
monitor_id = monitor_id,
|
||||
)
|
||||
chunks = []
|
||||
async for chunk in response.body_iterator:
|
||||
chunks.append(chunk)
|
||||
|
||||
assert chunks == ['data: {"choices":[{"delta":{"content":"hello"}}]}\n\n']
|
||||
[entry] = monitor.snapshot()
|
||||
assert chunks[0] == 'data: {"choices":[{"delta":{"content":"hello"}}]}\n\n'
|
||||
compact = "".join(chunks).replace(" ", "")
|
||||
assert '"finish_reason":"stop"' in compact
|
||||
assert chunks[-1] == "data: [DONE]\n\n"
|
||||
[entry] = result.monitor.snapshot()
|
||||
assert entry["status"] == "completed"
|
||||
assert entry["reply"] == "hello"
|
||||
assert monitor.active_count() == 0
|
||||
assert result.monitor.active_count() == 0
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
|
|
|||
134
studio/backend/tests/test_preview.py
Normal file
134
studio/backend/tests/test_preview.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# 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 json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types as _types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
||||
if _BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, _BACKEND_DIR)
|
||||
|
||||
_loggers_stub = _types.ModuleType("loggers")
|
||||
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
|
||||
sys.modules.setdefault("loggers", _loggers_stub)
|
||||
|
||||
from utils.models.checkpoints import (
|
||||
list_preview_targets,
|
||||
preview_ref,
|
||||
resolve_preview_checkpoint,
|
||||
)
|
||||
|
||||
|
||||
def _make_run(outputs: Path) -> tuple[Path, Path]:
|
||||
run = outputs / "unsloth_SmolLM-135M_1775412608"
|
||||
run.mkdir(parents = True)
|
||||
(run / "adapter_config.json").write_text(
|
||||
json.dumps({"base_model_name_or_path": "HuggingFaceTB/SmolLM-135M"})
|
||||
)
|
||||
ckpt = run / "checkpoint-60"
|
||||
ckpt.mkdir()
|
||||
(ckpt / "adapter_config.json").write_text(
|
||||
json.dumps({"base_model_name_or_path": "HuggingFaceTB/SmolLM-135M"})
|
||||
)
|
||||
return run, ckpt
|
||||
|
||||
|
||||
def _point_outputs_root_at(monkeypatch, outputs: Path) -> None:
|
||||
from utils.paths import storage_roots as _sr
|
||||
from utils.models import checkpoints as _ckpt
|
||||
|
||||
monkeypatch.setattr(_sr, "outputs_root", lambda: outputs)
|
||||
# checkpoints imported outputs_root by name; patch that alias too (preview_ref uses it).
|
||||
monkeypatch.setattr(_ckpt, "outputs_root", lambda: outputs)
|
||||
|
||||
|
||||
def test_resolve_main_adapter_and_checkpoint(tmp_path: Path, monkeypatch):
|
||||
outputs = tmp_path / "outputs"
|
||||
run, ckpt = _make_run(outputs)
|
||||
_point_outputs_root_at(monkeypatch, outputs)
|
||||
|
||||
assert resolve_preview_checkpoint(run.name) == run
|
||||
assert resolve_preview_checkpoint(run.name, "checkpoint-60") == ckpt
|
||||
|
||||
|
||||
def test_resolve_missing_raises_not_found(tmp_path: Path, monkeypatch):
|
||||
outputs = tmp_path / "outputs"
|
||||
_make_run(outputs)
|
||||
_point_outputs_root_at(monkeypatch, outputs)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
resolve_preview_checkpoint("does-not-exist")
|
||||
(outputs / "empty").mkdir()
|
||||
with pytest.raises(FileNotFoundError):
|
||||
resolve_preview_checkpoint("empty")
|
||||
|
||||
|
||||
def test_resolve_rejects_traversal(tmp_path: Path, monkeypatch):
|
||||
outputs = tmp_path / "outputs"
|
||||
_make_run(outputs)
|
||||
_point_outputs_root_at(monkeypatch, outputs)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
resolve_preview_checkpoint("..", "etc")
|
||||
|
||||
|
||||
def test_list_preview_targets_flattens_with_latest_flag(tmp_path: Path, monkeypatch):
|
||||
outputs = tmp_path / "outputs"
|
||||
run, _ = _make_run(outputs)
|
||||
_point_outputs_root_at(monkeypatch, outputs)
|
||||
|
||||
targets = list_preview_targets(str(outputs))
|
||||
by_ref = {t["ref"]: t for t in targets}
|
||||
|
||||
assert by_ref[run.name]["is_latest"] is True
|
||||
assert by_ref[run.name]["checkpoint"] is None
|
||||
assert by_ref[f"{run.name}/checkpoint-60"]["is_latest"] is False
|
||||
assert by_ref[f"{run.name}/checkpoint-60"]["checkpoint"] == "checkpoint-60"
|
||||
assert all(t["base_model"] == "HuggingFaceTB/SmolLM-135M" for t in targets)
|
||||
|
||||
|
||||
def test_preview_ref_flat_run_is_basename(tmp_path: Path, monkeypatch):
|
||||
outputs = tmp_path / "outputs"
|
||||
run, _ = _make_run(outputs)
|
||||
_point_outputs_root_at(monkeypatch, outputs)
|
||||
|
||||
assert preview_ref(str(run)) == run.name
|
||||
|
||||
|
||||
def test_preview_ref_preserves_one_level_nesting(tmp_path: Path, monkeypatch):
|
||||
outputs = tmp_path / "outputs"
|
||||
_point_outputs_root_at(monkeypatch, outputs)
|
||||
nested = outputs / "experiments" / "run1"
|
||||
nested.mkdir(parents = True)
|
||||
(nested / "adapter_config.json").write_text("{}")
|
||||
|
||||
# /p route supports run/checkpoint, so a single level of nesting survives.
|
||||
assert preview_ref(str(nested)) == "experiments/run1"
|
||||
|
||||
|
||||
def test_preview_ref_none_for_unpreviewable_or_too_deep(tmp_path: Path, monkeypatch):
|
||||
outputs = tmp_path / "outputs"
|
||||
_point_outputs_root_at(monkeypatch, outputs)
|
||||
|
||||
# Missing / no model artifact -> not previewable.
|
||||
assert preview_ref(None) is None
|
||||
empty = outputs / "empty"
|
||||
empty.mkdir(parents = True)
|
||||
assert preview_ref(str(empty)) is None
|
||||
|
||||
# Too deep for the two-segment /p route -> no dead link.
|
||||
deep = outputs / "a" / "b" / "run"
|
||||
deep.mkdir(parents = True)
|
||||
(deep / "adapter_config.json").write_text("{}")
|
||||
assert preview_ref(str(deep)) is None
|
||||
|
||||
# Outside outputs_root -> None.
|
||||
outside = tmp_path / "elsewhere"
|
||||
outside.mkdir()
|
||||
(outside / "adapter_config.json").write_text("{}")
|
||||
assert preview_ref(str(outside)) is None
|
||||
293
studio/backend/tests/test_preview_routes.py
Normal file
293
studio/backend/tests/test_preview_routes.py
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Security smoke for the public /p preview routes.
|
||||
|
||||
Exercises the route layer with a real ``preview_router`` while stubbing the
|
||||
expensive model calls (``load_model`` / ``openai_chat_completions``). Covers the
|
||||
public-surface guarantees: path-traversal rejection, request sanitization
|
||||
(tools / provider routing / use_adapter), asset-path containment, the page CSP
|
||||
header + HTML escaping, and that the preview lock is held until a streaming
|
||||
response is fully drained.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types as _types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
||||
if _BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, _BACKEND_DIR)
|
||||
|
||||
# Mirror test_preview.py: the real `loggers` package pulls in heavy handlers.
|
||||
_loggers_stub = _types.ModuleType("loggers")
|
||||
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
|
||||
sys.modules.setdefault("loggers", _loggers_stub)
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import routes.preview as preview
|
||||
from models.inference import ChatCompletionRequest
|
||||
|
||||
|
||||
def _make_run(outputs: Path, name: str = "demorun") -> Path:
|
||||
run = outputs / name
|
||||
run.mkdir(parents = True)
|
||||
(run / "adapter_config.json").write_text(
|
||||
json.dumps({"base_model_name_or_path": "HuggingFaceTB/SmolLM-135M"})
|
||||
)
|
||||
ckpt = run / "checkpoint-1"
|
||||
ckpt.mkdir()
|
||||
(ckpt / "adapter_config.json").write_text("{}")
|
||||
return run
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def captured():
|
||||
return {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch, captured):
|
||||
outputs = tmp_path / "outputs"
|
||||
_make_run(outputs)
|
||||
|
||||
# resolve_preview_checkpoint -> resolve_output_dir -> outputs_root().
|
||||
from utils.paths import storage_roots as _sr
|
||||
|
||||
monkeypatch.setattr(_sr, "outputs_root", lambda: outputs)
|
||||
|
||||
async def _fake_load_model(load_req, request, subject):
|
||||
captured["load_path"] = load_req.model_path
|
||||
return None
|
||||
|
||||
async def _fake_chat(payload, request, subject):
|
||||
captured["payload"] = payload
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(preview, "load_model", _fake_load_model)
|
||||
monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(preview.router, prefix = "/p")
|
||||
app.dependency_overrides[preview.get_current_subject] = lambda: "admin"
|
||||
# raise_server_exceptions=False so a 5xx surfaces as a response, not a throw.
|
||||
return TestClient(app, raise_server_exceptions = False)
|
||||
|
||||
|
||||
# ── Page rendering ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_page_renders_with_csp(client):
|
||||
r = client.get("/p/demorun")
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers["content-type"]
|
||||
csp = r.headers.get("content-security-policy", "")
|
||||
assert "default-src 'self'" in csp
|
||||
assert "base-uri 'none'" in csp
|
||||
|
||||
|
||||
def test_page_escapes_title(tmp_path, monkeypatch, captured):
|
||||
outputs = tmp_path / "outputs"
|
||||
# Run dir name carries an HTML-special char; the page must escape it.
|
||||
_make_run(outputs, name = "a<b")
|
||||
from utils.paths import storage_roots as _sr
|
||||
|
||||
monkeypatch.setattr(_sr, "outputs_root", lambda: outputs)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(preview.router, prefix = "/p")
|
||||
c = TestClient(app, raise_server_exceptions = False)
|
||||
|
||||
r = c.get("/p/a%3Cb")
|
||||
assert r.status_code == 200
|
||||
assert "a<b" not in r.text
|
||||
assert "a<b" in r.text
|
||||
|
||||
|
||||
def test_models_endpoint_shape(client):
|
||||
r = client.get("/p/demorun/v1/models")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["object"] == "list"
|
||||
assert body["data"][0]["id"] == "demorun"
|
||||
assert body["data"][0]["owned_by"] == "unsloth-studio"
|
||||
|
||||
|
||||
def test_list_previews_builds_urls(client, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
preview,
|
||||
"list_preview_targets",
|
||||
lambda: [{"ref": "demorun", "is_latest": True}],
|
||||
)
|
||||
r = client.get("/p")
|
||||
assert r.status_code == 200
|
||||
data = r.json()["data"]
|
||||
assert data[0]["url"].endswith("/p/demorun/v1")
|
||||
|
||||
|
||||
# ── Path traversal / containment ────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"/p/..", # parent segment as run
|
||||
"/p/%2e%2e/etc", # encoded traversal
|
||||
"/p/..%2f..%2fetc/v1/models", # encoded slash traversal
|
||||
"/p/does-not-exist", # unknown run
|
||||
],
|
||||
)
|
||||
def test_traversal_and_missing_rejected(client, path):
|
||||
r = client.get(path)
|
||||
assert r.status_code in (400, 404), (path, r.status_code)
|
||||
|
||||
|
||||
def test_chat_traversal_rejected(client):
|
||||
r = client.post(
|
||||
"/p/..%2f..%2fetc/v1/chat/completions",
|
||||
json = {"messages": [{"role": "user", "content": "hi"}]},
|
||||
)
|
||||
assert r.status_code in (400, 404)
|
||||
|
||||
|
||||
# ── Asset containment ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"asset",
|
||||
[
|
||||
"../../../../etc/passwd", # escapes dist
|
||||
"secrets.txt", # non-allowlisted suffix
|
||||
"nope.png", # allowlisted suffix but missing
|
||||
],
|
||||
)
|
||||
def test_asset_path_contained(client, asset):
|
||||
r = client.get(f"/p/_assets/{asset}")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ── Request sanitization ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_chat_payload_sanitized(client, captured):
|
||||
r = client.post(
|
||||
"/p/demorun/v1/chat/completions",
|
||||
json = {
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"tools": [{"type": "function", "function": {"name": "rm", "parameters": {}}}],
|
||||
"enable_tools": True,
|
||||
"enabled_tools": ["python"],
|
||||
"mcp_enabled": True,
|
||||
"bypass_permissions": True,
|
||||
"provider_id": "p1",
|
||||
"provider_type": "custom",
|
||||
"provider_base_url": "http://evil.example/v1",
|
||||
"external_model": "gpt-4o",
|
||||
"use_adapter": False,
|
||||
"confirm_tool_calls": True,
|
||||
"session_id": "abc",
|
||||
"rag_scope": {"project_id": "x"},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
p = captured["payload"]
|
||||
assert isinstance(p, ChatCompletionRequest)
|
||||
# Tools / code-exec off.
|
||||
assert p.tools is None
|
||||
assert p.enable_tools is False
|
||||
assert p.enabled_tools is None
|
||||
assert p.mcp_enabled is False
|
||||
assert p.bypass_permissions is False
|
||||
# Tool-loop levers neutralized regardless of the tool gate.
|
||||
assert p.confirm_tool_calls is False
|
||||
assert p.session_id is None
|
||||
assert p.rag_scope is None
|
||||
# Provider routing stripped so /p can't proxy an arbitrary endpoint.
|
||||
assert p.provider_id is None
|
||||
assert p.provider_type is None
|
||||
assert p.provider_base_url is None
|
||||
assert p.external_model is None
|
||||
# Adapter pinned on for LoRA: a caller can't flip the shared backend to base.
|
||||
assert p.use_adapter is True
|
||||
# Loads the resolved checkpoint dir, not an attacker-supplied path.
|
||||
assert captured["load_path"].endswith("demorun")
|
||||
|
||||
|
||||
def test_merged_checkpoint_strips_use_adapter(tmp_path, monkeypatch, captured):
|
||||
# Merged (non-LoRA) checkpoint: no adapter to toggle, so use_adapter -> None.
|
||||
outputs = tmp_path / "outputs"
|
||||
merged = outputs / "mergedrun"
|
||||
merged.mkdir(parents = True)
|
||||
(merged / "config.json").write_text(json.dumps({"_name_or_path": "some/base"}))
|
||||
|
||||
from utils.paths import storage_roots as _sr
|
||||
|
||||
monkeypatch.setattr(_sr, "outputs_root", lambda: outputs)
|
||||
|
||||
async def _fake_load(load_req, request, subject):
|
||||
return None
|
||||
|
||||
async def _fake_chat(payload, request, subject):
|
||||
captured["payload"] = payload
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(preview, "load_model", _fake_load)
|
||||
monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(preview.router, prefix = "/p")
|
||||
c = TestClient(app, raise_server_exceptions = False)
|
||||
r = c.post(
|
||||
"/p/mergedrun/v1/chat/completions",
|
||||
json = {"messages": [{"role": "user", "content": "hi"}], "use_adapter": False},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert captured["payload"].use_adapter is None
|
||||
|
||||
|
||||
# ── Streaming lock lifetime ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_streaming_holds_lock_until_drained(tmp_path, monkeypatch, captured):
|
||||
outputs = tmp_path / "outputs"
|
||||
_make_run(outputs)
|
||||
from utils.paths import storage_roots as _sr
|
||||
|
||||
monkeypatch.setattr(_sr, "outputs_root", lambda: outputs)
|
||||
|
||||
async def _fake_load_model(load_req, request, subject):
|
||||
return None
|
||||
|
||||
async def _gen():
|
||||
yield b"data: {}\n\n"
|
||||
yield b"data: [DONE]\n\n"
|
||||
|
||||
async def _fake_chat(payload, request, subject):
|
||||
return StreamingResponse(_gen())
|
||||
|
||||
monkeypatch.setattr(preview, "load_model", _fake_load_model)
|
||||
monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat)
|
||||
|
||||
async def _run():
|
||||
assert not preview._preview_lock.locked()
|
||||
payload = ChatCompletionRequest(messages = [{"role": "user", "content": "hi"}])
|
||||
resp = await preview._serve_chat("demorun", None, payload, request = None)
|
||||
# Lock must still be held: a second checkpoint must not swap the backend
|
||||
# mid-stream.
|
||||
assert preview._preview_lock.locked()
|
||||
chunks = [c async for c in resp.body_iterator]
|
||||
# Released only after the stream fully drains.
|
||||
assert not preview._preview_lock.locked()
|
||||
return chunks
|
||||
|
||||
chunks = asyncio.run(_run())
|
||||
assert any(b"[DONE]" in c for c in chunks)
|
||||
assert not preview._preview_lock.locked()
|
||||
|
|
@ -59,8 +59,10 @@ from models.inference import (
|
|||
ResponsesUsage,
|
||||
)
|
||||
from routes.inference import (
|
||||
_SameTaskStreamingResponse,
|
||||
_build_chat_request,
|
||||
_chat_tool_calls_to_responses_output,
|
||||
_extract_responses_reasoning,
|
||||
_normalise_responses_input,
|
||||
_responses_tool_output_content,
|
||||
_responses_non_streaming,
|
||||
|
|
@ -782,6 +784,15 @@ class TestResponsesNonStreamingAdapter:
|
|||
assert "<think>" not in body["output"][1]["content"][0]["text"]
|
||||
assert "</think>" not in body["output"][1]["content"][0]["text"]
|
||||
|
||||
def test_unclosed_think_block_extracts_as_reasoning(self):
|
||||
reasoning, visible = _extract_responses_reasoning(
|
||||
"<think>partial plan",
|
||||
parse_think_markers = True,
|
||||
)
|
||||
|
||||
assert reasoning == "partial plan"
|
||||
assert visible == ""
|
||||
|
||||
def test_monitor_records_translated_visible_text(self, monkeypatch):
|
||||
import routes.inference as inf_mod
|
||||
|
||||
|
|
@ -927,6 +938,38 @@ class TestResponsesNonStreamingAdapter:
|
|||
assert [item["type"] for item in body["output"]] == ["message"]
|
||||
assert body["output"][0]["content"][0]["text"] == "show <think>x</think> tags"
|
||||
|
||||
def test_reasoning_capable_gguf_parses_think_tags_by_default(self, monkeypatch):
|
||||
body = self._run_with_message(
|
||||
monkeypatch,
|
||||
{"content": "<think>plan</think>answer"},
|
||||
llama_backend = SimpleNamespace(
|
||||
is_loaded = True,
|
||||
reasoning_always_on = False,
|
||||
supports_reasoning = True,
|
||||
),
|
||||
)
|
||||
|
||||
assert [item["type"] for item in body["output"]] == ["reasoning", "message"]
|
||||
assert body["output"][0]["content"] == [{"type": "reasoning_text", "text": "plan"}]
|
||||
assert body["output"][1]["content"][0]["text"] == "answer"
|
||||
|
||||
def test_reasoning_capable_gguf_sanitizes_think_tags_when_disabled(self, monkeypatch):
|
||||
payload = ResponsesRequest(input = "hi", reasoning = {"effort": "none"})
|
||||
body = self._run_with_message(
|
||||
monkeypatch,
|
||||
{"content": "<think>leaked</think>answer"},
|
||||
payload = payload,
|
||||
llama_backend = SimpleNamespace(
|
||||
is_loaded = True,
|
||||
reasoning_always_on = False,
|
||||
supports_reasoning = True,
|
||||
),
|
||||
)
|
||||
|
||||
assert [item["type"] for item in body["output"]] == ["reasoning", "message"]
|
||||
assert body["output"][0]["content"] == [{"type": "reasoning_text", "text": "leaked"}]
|
||||
assert body["output"][1]["content"][0]["text"] == "answer"
|
||||
|
||||
def test_structured_reasoning_content_extracts_text_parts(self, monkeypatch):
|
||||
body = self._run_with_message(
|
||||
monkeypatch,
|
||||
|
|
@ -949,7 +992,7 @@ class TestResponsesNonStreamingAdapter:
|
|||
assert [item["type"] for item in body["output"]] == ["message"]
|
||||
assert body["output"][0]["content"][0]["text"] == "33"
|
||||
|
||||
def test_reasoning_only_is_also_visible_message_text(self, monkeypatch):
|
||||
def test_reasoning_only_stays_out_of_visible_message_text(self, monkeypatch):
|
||||
payload = ResponsesRequest(input = "hi", reasoning = {"effort": "high"})
|
||||
body = self._run_with_message(
|
||||
monkeypatch,
|
||||
|
|
@ -957,9 +1000,8 @@ class TestResponsesNonStreamingAdapter:
|
|||
payload = payload,
|
||||
)
|
||||
|
||||
assert [item["type"] for item in body["output"]] == ["reasoning", "message"]
|
||||
assert [item["type"] for item in body["output"]] == ["reasoning"]
|
||||
assert body["output"][0]["content"][0]["text"] == "plan"
|
||||
assert body["output"][1]["content"][0]["text"] == "plan"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
|
|
@ -1033,6 +1075,36 @@ class TestResponsesStreamAdapter:
|
|||
),
|
||||
)
|
||||
|
||||
def test_stream_response_avoids_legacy_receive_watcher(self, monkeypatch):
|
||||
self._install_stream_mock(
|
||||
monkeypatch,
|
||||
[{"choices": [{"delta": {"content": "33"}}]}],
|
||||
)
|
||||
payload = ResponsesRequest(input = "hi", stream = True)
|
||||
messages = [ChatMessage(role = "user", content = "hi")]
|
||||
|
||||
async def run():
|
||||
response = await _responses_stream(payload, messages, self._Request())
|
||||
assert isinstance(response, _SameTaskStreamingResponse)
|
||||
|
||||
sent = []
|
||||
|
||||
async def receive():
|
||||
raise AssertionError("Responses streams poll disconnects in the generator")
|
||||
|
||||
async def send(message):
|
||||
sent.append(message)
|
||||
|
||||
await response({"type": "http", "asgi": {"spec_version": "2.3"}}, receive, send)
|
||||
return sent
|
||||
|
||||
sent = asyncio.run(run())
|
||||
|
||||
assert sent[0]["type"] == "http.response.start"
|
||||
body = b"".join(message.get("body", b"") for message in sent).decode()
|
||||
assert "response.output_text.delta" in body
|
||||
assert '"delta":"33"' in body.replace(" ", "")
|
||||
|
||||
def test_split_think_markers_stream_as_reasoning_and_visible_text(self, monkeypatch):
|
||||
chunks = [
|
||||
{"choices": [{"delta": {"content": "<thi"}}]},
|
||||
|
|
@ -1286,7 +1358,7 @@ class TestResponsesStreamAdapter:
|
|||
assert entry["status"] == "completed"
|
||||
assert entry["reply"] == "tail"
|
||||
|
||||
def test_reasoning_only_fallback_updates_monitor(self, monkeypatch):
|
||||
def test_reasoning_only_stream_does_not_update_visible_monitor_reply(self, monkeypatch):
|
||||
import routes.inference as inf_mod
|
||||
|
||||
class FakeExtractor:
|
||||
|
|
@ -1327,15 +1399,16 @@ class TestResponsesStreamAdapter:
|
|||
|
||||
lines = asyncio.run(run())
|
||||
|
||||
assert self._payloads(lines, "response.output_text.delta")[-1]["delta"] == "plan"
|
||||
assert self._payloads(lines, "response.output_text.delta") == []
|
||||
assert self._payloads(lines, "response.reasoning_text.delta")[-1]["delta"] == "plan"
|
||||
[entry] = monitor.snapshot()
|
||||
assert entry["status"] == "completed"
|
||||
assert entry["reply"] == "plan"
|
||||
assert entry["reply"] == ""
|
||||
|
||||
def test_literal_think_tags_stream_as_visible_text_without_reasoning_request(self, monkeypatch):
|
||||
def test_reasoning_capable_gguf_stream_parses_think_tags_by_default(self, monkeypatch):
|
||||
chunks = [
|
||||
{"choices": [{"delta": {"content": "show <thi"}}]},
|
||||
{"choices": [{"delta": {"content": "nk>x</think> tags"}}]},
|
||||
{"choices": [{"delta": {"content": "<thi"}}]},
|
||||
{"choices": [{"delta": {"content": "nk>plan</think>answer"}}]},
|
||||
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
|
||||
]
|
||||
self._install_stream_mock(monkeypatch, chunks)
|
||||
|
|
@ -1350,13 +1423,15 @@ class TestResponsesStreamAdapter:
|
|||
|
||||
reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta")
|
||||
text_deltas = self._payloads(lines, "response.output_text.delta")
|
||||
assert reasoning_deltas == []
|
||||
assert "".join(event["delta"] for event in text_deltas) == "show <think>x</think> tags"
|
||||
assert "".join(event["delta"] for event in reasoning_deltas) == "plan"
|
||||
assert "".join(event["delta"] for event in text_deltas) == "answer"
|
||||
completed = self._payloads(lines, "response.completed")[0]
|
||||
assert [item["type"] for item in completed["response"]["output"]] == ["message"]
|
||||
assert completed["response"]["output"][0]["content"][0]["text"] == (
|
||||
"show <think>x</think> tags"
|
||||
)
|
||||
assert [item["type"] for item in completed["response"]["output"]] == [
|
||||
"reasoning",
|
||||
"message",
|
||||
]
|
||||
assert completed["response"]["output"][0]["content"][0]["text"] == "plan"
|
||||
assert completed["response"]["output"][1]["content"][0]["text"] == "answer"
|
||||
|
||||
def test_non_reasoning_gguf_stream_keeps_literal_think_tags_visible(self, monkeypatch):
|
||||
chunks = [
|
||||
|
|
@ -1384,7 +1459,7 @@ class TestResponsesStreamAdapter:
|
|||
"show <think>x</think> tags"
|
||||
)
|
||||
|
||||
def test_reasoning_only_streams_as_visible_message_text(self, monkeypatch):
|
||||
def test_reasoning_only_stream_stays_out_of_visible_message_text(self, monkeypatch):
|
||||
chunks = [
|
||||
{"choices": [{"delta": {"content": "<think>plan</think>"}}]},
|
||||
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
|
||||
|
|
@ -1402,14 +1477,34 @@ class TestResponsesStreamAdapter:
|
|||
reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta")
|
||||
text_deltas = self._payloads(lines, "response.output_text.delta")
|
||||
assert "".join(event["delta"] for event in reasoning_deltas) == "plan"
|
||||
assert "".join(event["delta"] for event in text_deltas) == "plan"
|
||||
assert text_deltas == []
|
||||
completed = self._payloads(lines, "response.completed")[0]
|
||||
assert [item["type"] for item in completed["response"]["output"]] == [
|
||||
"reasoning",
|
||||
"message",
|
||||
]
|
||||
assert [item["type"] for item in completed["response"]["output"]] == ["reasoning"]
|
||||
assert completed["response"]["output"][0]["content"][0]["text"] == "plan"
|
||||
|
||||
def test_unclosed_think_stream_stays_out_of_visible_message_text(self, monkeypatch):
|
||||
chunks = [
|
||||
{"choices": [{"delta": {"content": "<thi"}}]},
|
||||
{"choices": [{"delta": {"content": "nk>plan"}}]},
|
||||
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
|
||||
]
|
||||
self._install_stream_mock(monkeypatch, chunks)
|
||||
payload = ResponsesRequest(input = "hi", stream = True, reasoning = {"effort": "high"})
|
||||
messages = [ChatMessage(role = "user", content = "hi")]
|
||||
|
||||
async def run():
|
||||
response = await _responses_stream(payload, messages, self._Request())
|
||||
return await self._collect(response)
|
||||
|
||||
lines = asyncio.run(run())
|
||||
|
||||
reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta")
|
||||
text_deltas = self._payloads(lines, "response.output_text.delta")
|
||||
assert "".join(event["delta"] for event in reasoning_deltas) == "plan"
|
||||
assert text_deltas == []
|
||||
completed = self._payloads(lines, "response.completed")[0]
|
||||
assert [item["type"] for item in completed["response"]["output"]] == ["reasoning"]
|
||||
assert completed["response"]["output"][0]["content"][0]["text"] == "plan"
|
||||
assert completed["response"]["output"][1]["content"][0]["text"] == "plan"
|
||||
|
||||
def test_structured_reasoning_content_streams_as_reasoning(self, monkeypatch):
|
||||
chunks = [
|
||||
|
|
|
|||
|
|
@ -203,6 +203,20 @@ def test_detect_safetensors_features_function_xml_format_keeps_tools_on():
|
|||
assert flags["supports_tools"] is True
|
||||
|
||||
|
||||
def test_detect_safetensors_features_gemma_native_tool_call_keeps_tools_on():
|
||||
"""Gemma 4 emits <|tool_call>call:name{...}<tool_call|>, which the shared
|
||||
parser now reads, so the gate must not suppress tools for it."""
|
||||
from routes.inference import _detect_safetensors_features
|
||||
|
||||
tpl_with_gemma_native = (
|
||||
"{%- if tools -%}Tool call format: "
|
||||
"<|tool_call>call:name{key:value}<tool_call|>{%- endif -%}"
|
||||
)
|
||||
backend = SimpleNamespace(active_model_name = "unsloth/gemma-4-12b-it")
|
||||
flags = _detect_safetensors_features(backend, tpl_with_gemma_native)
|
||||
assert flags["supports_tools"] is True
|
||||
|
||||
|
||||
# Qwen3.5 family pin: the live GGUF + safetensors templates both wrap tool
|
||||
# calls as ``<tool_call>\n<function=name>...``. Faithful slice so the
|
||||
# classifier never silently regresses for this family.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ calls, tool-result feedback, bad-JSON heal, duplicate-call short-circuit,
|
|||
``__IMAGES__`` sentinel stripping, executor errors, cancel, and the iteration cap.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from typing import cast
|
||||
|
||||
|
|
@ -62,6 +63,51 @@ class TestParser:
|
|||
assert parse_tool_calls_from_text(text)[0]["function"]["name"] == "python"
|
||||
assert parse_tool_calls_from_text(text, allow_incomplete = False) == []
|
||||
|
||||
def test_gemma_native_tool_call(self):
|
||||
text = '<|tool_call>call:terminal{command:"ls -la",workdir:"."}<tool_call|>'
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
assert result[0]["function"]["name"] == "terminal"
|
||||
args = json.loads(result[0]["function"]["arguments"])
|
||||
assert args == {"command": "ls -la", "workdir": "."}
|
||||
|
||||
def test_gemma_native_tool_call_template_quotes(self):
|
||||
text = '<|tool_call>call:web_search{query:<|"|>openai news<|"|>}<tool_call|>'
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
assert result[0]["function"]["name"] == "web_search"
|
||||
assert json.loads(result[0]["function"]["arguments"]) == {"query": "openai news"}
|
||||
|
||||
def test_gemma_native_tool_call_template_quotes_escape_backslashes(self):
|
||||
text = r'<|tool_call>call:ls{path:<|"|>C:\Users\wasim\repo<|"|>}<tool_call|>'
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
assert result[0]["function"]["name"] == "ls"
|
||||
assert json.loads(result[0]["function"]["arguments"]) == {"path": r"C:\Users\wasim\repo"}
|
||||
|
||||
def test_gemma_native_tool_call_hyphenated_argument_name(self):
|
||||
text = '<|tool_call>call:mcp__srv__create-issue{issue-title:"Bug report"}<tool_call|>'
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
assert result[0]["function"]["name"] == "mcp__srv__create-issue"
|
||||
assert json.loads(result[0]["function"]["arguments"]) == {"issue-title": "Bug report"}
|
||||
|
||||
def test_gemma_native_tool_call_keeps_braces_inside_string_value(self):
|
||||
text = '<|tool_call>call:terminal{command:"echo {foo:bar}"}<tool_call|>'
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
assert result[0]["function"]["name"] == "terminal"
|
||||
assert json.loads(result[0]["function"]["arguments"]) == {"command": "echo {foo:bar}"}
|
||||
|
||||
def test_gemma_native_tool_call_bare_string_values(self):
|
||||
text = "<|tool_call>call:get_weather{location:Tokyo,unit:celsius}<tool_call|>"
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
assert json.loads(result[0]["function"]["arguments"]) == {
|
||||
"location": "Tokyo",
|
||||
"unit": "celsius",
|
||||
}
|
||||
|
||||
def test_xml_function_call(self):
|
||||
text = "<function=python><parameter=code>print('hi')</parameter></function>"
|
||||
result = parse_tool_calls_from_text(text)
|
||||
|
|
@ -121,6 +167,7 @@ class TestParser:
|
|||
|
||||
def test_has_tool_signal(self):
|
||||
assert has_tool_signal("blah <tool_call> x")
|
||||
assert has_tool_signal("blah <|tool_call>call:terminal")
|
||||
assert has_tool_signal("hi <function=foo>...")
|
||||
assert not has_tool_signal("hello world")
|
||||
|
||||
|
|
@ -139,6 +186,8 @@ class TestParser:
|
|||
def test_strip_markup_closed(self):
|
||||
text = "before <tool_call>{}</tool_call> after"
|
||||
assert strip_tool_markup(text) == "before after"
|
||||
text = 'before <|tool_call>call:terminal{command:"ls"}<tool_call|> after'
|
||||
assert strip_tool_markup(text) == "before after"
|
||||
|
||||
def test_strip_markup_unclosed_final(self):
|
||||
text = "before <tool_call>{partial"
|
||||
|
|
@ -146,6 +195,7 @@ class TestParser:
|
|||
assert strip_tool_markup(text, final = True) == "before"
|
||||
# Without final=True the unclosed run is preserved.
|
||||
assert "partial" in strip_tool_markup(text)
|
||||
assert strip_tool_markup("before <|tool_call>call:terminal{", final = True) == "before"
|
||||
|
||||
def test_streaming_strip_respects_disabled_healing(self):
|
||||
raw = 'before <tool_call>{"name":"web_search"'
|
||||
|
|
|
|||
|
|
@ -31,11 +31,14 @@ from run import _cloudflare_tunnel_should_start as should_start # noqa: E402
|
|||
# --no-cloudflare always wins.
|
||||
(False, "0.0.0.0", False, False, False, False),
|
||||
(False, "127.0.0.1", True, False, False, False),
|
||||
# api-only and Colab never tunnel.
|
||||
# Non-secure api-only never tunnels (Tauri).
|
||||
(True, "0.0.0.0", False, True, False, False),
|
||||
(True, "127.0.0.1", True, True, False, False),
|
||||
# --secure tunnels even api-only (headless secure API server).
|
||||
(True, "127.0.0.1", True, True, False, True),
|
||||
# Colab never tunnels, even --secure.
|
||||
(True, "0.0.0.0", False, False, True, False),
|
||||
(True, "127.0.0.1", True, False, True, False),
|
||||
(True, "127.0.0.1", True, True, True, False),
|
||||
],
|
||||
)
|
||||
def test_cloudflare_gate(cloudflare, host, secure, api_only, is_colab, expected):
|
||||
|
|
@ -162,3 +165,46 @@ def test_failclosed_message_present_in_source():
|
|||
"A secure Cloudflare link is not allowed, use --no-secure which provides a 0.0.0.0 link"
|
||||
in src
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"api_only,secure,expected",
|
||||
[
|
||||
(False, False, ["*"]), # plain server: any origin
|
||||
(False, True, ["*"]), # secure UI server: any origin
|
||||
(True, True, ["*"]), # secure api-only: remote browsers need any origin
|
||||
(True, False, "tauri"), # local api-only: locked to the Tauri app
|
||||
],
|
||||
)
|
||||
def test_cors_origins_for_mode(api_only, secure, expected):
|
||||
from utils.host_policy import cors_origins_for_mode
|
||||
origins = cors_origins_for_mode(api_only = api_only, secure = secure)
|
||||
if expected == "tauri":
|
||||
assert origins != ["*"] and any(o.startswith("tauri://") for o in origins)
|
||||
else:
|
||||
assert origins == expected
|
||||
|
||||
|
||||
def test_run_server_exports_secure_env_for_cors():
|
||||
# run_server must export UNSLOTH_SECURE before importing main so the CORS
|
||||
# profile can tell remote secure serving from local Tauri use.
|
||||
src = (_BACKEND / "run.py").read_text(encoding = "utf-8")
|
||||
assert 'os.environ["UNSLOTH_SECURE"] = "1"' in src
|
||||
|
||||
|
||||
def test_run_server_emit_tauri_port_defaults_on():
|
||||
# Default on keeps the desktop app's stdout contract; the headless
|
||||
# `run --api-only` path opts out explicitly.
|
||||
import inspect
|
||||
|
||||
import run
|
||||
|
||||
params = inspect.signature(run.run_server).parameters
|
||||
assert "emit_tauri_port" in params
|
||||
assert params["emit_tauri_port"].default is True
|
||||
|
||||
|
||||
def test_tauri_port_print_is_gated_in_source():
|
||||
# The TAURI_PORT line must depend on emit_tauri_port, not api_only alone.
|
||||
src = (_BACKEND / "run.py").read_text(encoding = "utf-8")
|
||||
assert "if api_only and emit_tauri_port:" in src
|
||||
|
|
|
|||
|
|
@ -106,6 +106,54 @@ class TestParityWithJsonStyle:
|
|||
assert json.loads(js[0]["function"]["arguments"]) == {"query": q}
|
||||
|
||||
|
||||
class TestGemmaNativeStyle:
|
||||
def test_closed_native_call_with_trailing_prose_is_accepted(self):
|
||||
text = (
|
||||
'<|tool_call>call:terminal{command:"ls -la",workdir:"."}<tool_call|>' " running it now"
|
||||
)
|
||||
calls = parse_tool_calls_from_text(text, allow_incomplete = False)
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["function"]["name"] == "terminal"
|
||||
assert json.loads(calls[0]["function"]["arguments"]) == {
|
||||
"command": "ls -la",
|
||||
"workdir": ".",
|
||||
}
|
||||
|
||||
def test_unclosed_native_call_requires_healing(self):
|
||||
text = '<|tool_call>call:terminal{command:"ls"}'
|
||||
assert parse_tool_calls_from_text(text, allow_incomplete = False) == []
|
||||
calls = parse_tool_calls_from_text(text, allow_incomplete = True)
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["function"]["name"] == "terminal"
|
||||
|
||||
def test_hyphenated_native_argument_name_is_accepted(self):
|
||||
text = '<|tool_call>call:mcp__srv__create-issue{issue-title:"Bug report"}<tool_call|>'
|
||||
calls = parse_tool_calls_from_text(text, allow_incomplete = False)
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["function"]["name"] == "mcp__srv__create-issue"
|
||||
assert json.loads(calls[0]["function"]["arguments"]) == {"issue-title": "Bug report"}
|
||||
|
||||
def test_native_template_quotes_preserve_windows_path(self):
|
||||
text = r'<|tool_call>call:ls{path:<|"|>C:\Users\wasim\repo<|"|>}<tool_call|>'
|
||||
calls = parse_tool_calls_from_text(text, allow_incomplete = False)
|
||||
assert len(calls) == 1
|
||||
assert json.loads(calls[0]["function"]["arguments"]) == {"path": r"C:\Users\wasim\repo"}
|
||||
|
||||
def test_bare_unquoted_string_values_are_accepted(self):
|
||||
# Gemma can emit enum/string args unquoted; bare JSON scalars stay typed.
|
||||
text = (
|
||||
"<|tool_call>call:get_weather{location:Tokyo,unit:celsius,days:3,live:true}<tool_call|>"
|
||||
)
|
||||
calls = parse_tool_calls_from_text(text, allow_incomplete = False)
|
||||
assert len(calls) == 1
|
||||
assert json.loads(calls[0]["function"]["arguments"]) == {
|
||||
"location": "Tokyo",
|
||||
"unit": "celsius",
|
||||
"days": 3,
|
||||
"live": True,
|
||||
}
|
||||
|
||||
|
||||
class TestHealingPathUnaffected:
|
||||
def test_auto_heal_still_repairs_unclosed_function(self):
|
||||
text = "<function=web_search><parameter=query>cats"
|
||||
|
|
|
|||
|
|
@ -125,6 +125,14 @@ def test_strips_orphan_closing_tag():
|
|||
# Mid-string </parameter> intentionally preserved (see preserve test).
|
||||
|
||||
|
||||
def test_strips_gemma_native_orphan_closing_tag():
|
||||
cleaned = _TOOL_XML_RE.sub("", "Tool call drained.<tool_call|>Visible tail.")
|
||||
|
||||
assert "<tool_call|>" not in cleaned
|
||||
assert "Tool call drained." in cleaned
|
||||
assert "Visible tail." in cleaned
|
||||
|
||||
|
||||
# ── Tail-only </parameter> (PR #5735 follow-up) ───────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -69,3 +69,14 @@ def test_default_spec_matches_table(monkeypatch):
|
|||
mod = _load_module(monkeypatch)
|
||||
assert mod._TORCHAO_DEFAULT_SPEC == "torchao==0.14.0"
|
||||
assert mod._select_torchao_spec("2.9.0") == mod._TORCHAO_DEFAULT_SPEC
|
||||
|
||||
|
||||
def test_skips_torchao_on_windows_rocm():
|
||||
"""The overrides step must skip torchao on Windows ROCm: no working build exists
|
||||
there (it imports an absent c10d backend and crashes transformers.quantizers),
|
||||
so the installer skips it and relies on the runtime stub instead."""
|
||||
source = _INSTALL_SCRIPT.read_text(encoding = "utf-8")
|
||||
# Branches on the Windows-ROCm marker set by _ensure_rocm_torch ...
|
||||
assert "elif _rocm_windows_torch_installed:" in source
|
||||
# ... and reports the skip in the progress label.
|
||||
assert "dependency overrides (skipped, Windows ROCm)" in source
|
||||
|
|
|
|||
|
|
@ -41,8 +41,20 @@ from utils.models.model_config import (
|
|||
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def _clear_vision_cache():
|
||||
"""Ensure every test starts with a fresh cache."""
|
||||
def _clear_vision_cache(tmp_path, monkeypatch):
|
||||
"""Ensure every test starts with a fresh cache, from an empty working dir.
|
||||
|
||||
``is_vision_model`` calls ``is_local_path`` first: any relative model id that
|
||||
happens to exist on disk (``Path(name).exists()``) is treated as a local
|
||||
model, short-circuiting before the mocked detection internals run. The CI cwd
|
||||
(``studio/backend``) and the HF cache can contain dirs whose names collide
|
||||
with the synthetic remote ids used here (``org/my-vlm``, ``model-a``,
|
||||
``broken/model`` ...), which made these tests fail with "called 0 times".
|
||||
Running each test from a fresh empty ``tmp_path`` removes that collision
|
||||
while leaving the real ``is_local_path`` logic intact (the local-GGUF tests
|
||||
pass absolute ``tmp_path`` paths, unaffected by cwd).
|
||||
"""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_vision_detection_cache.clear()
|
||||
yield
|
||||
_vision_detection_cache.clear()
|
||||
|
|
|
|||
|
|
@ -125,6 +125,12 @@ def is_anthropic_path(path: str) -> bool:
|
|||
return path.startswith("/v1/messages")
|
||||
|
||||
|
||||
def wants_api_error_envelope(path: str) -> bool:
|
||||
"""True for the OpenAI/Anthropic-compatible surfaces: the ``/v1/*`` mount and
|
||||
the preview ``/p/<run>[/<ckpt>]/v1/*`` mount."""
|
||||
return path.startswith("/v1/") or (path.startswith("/p/") and "/v1/" in path)
|
||||
|
||||
|
||||
def error_body_for_path(
|
||||
path,
|
||||
message,
|
||||
|
|
@ -183,15 +189,16 @@ def _summarize_validation_errors(errors) -> tuple:
|
|||
def install_api_error_handlers(app) -> None:
|
||||
"""Register validation + HTTPException handlers that emit ``/v1/*`` envelopes.
|
||||
|
||||
Both handlers are global but only transform responses for paths starting with
|
||||
``/v1/``. Non-``/v1/`` paths reproduce FastAPI's default ``{"detail": ...}``
|
||||
behavior exactly so the Studio frontend keeps working.
|
||||
Both handlers are global but only transform responses for OpenAI/Anthropic-
|
||||
compatible surfaces (see :func:`wants_api_error_envelope`: the ``/v1/*`` mount
|
||||
and the preview ``/p/.../v1/*`` mount). Every other path reproduces FastAPI's
|
||||
default ``{"detail": ...}`` behavior exactly so the Studio frontend keeps working.
|
||||
"""
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def _handle_validation_error(request, exc):
|
||||
path = request.url.path
|
||||
if path.startswith("/v1/"):
|
||||
if wants_api_error_envelope(path):
|
||||
summary, param = _summarize_validation_errors(exc.errors())
|
||||
return JSONResponse(
|
||||
status_code = 400,
|
||||
|
|
@ -211,7 +218,7 @@ def install_api_error_handlers(app) -> None:
|
|||
# default http_exception_handler, which returns a bodiless Response.
|
||||
if not is_body_allowed_for_status_code(exc.status_code):
|
||||
return Response(status_code = exc.status_code, headers = headers)
|
||||
if path.startswith("/v1/"):
|
||||
if wants_api_error_envelope(path):
|
||||
detail = exc.detail
|
||||
# Already a fully-formed envelope: pass through untouched.
|
||||
if isinstance(detail, dict) and ("error" in detail or detail.get("type") == "error"):
|
||||
|
|
|
|||
|
|
@ -34,6 +34,26 @@ def is_external_host(host: str) -> bool:
|
|||
return host.lower() not in _LOOPBACK_HOSTS
|
||||
|
||||
|
||||
# Tauri desktop webview origins. api-only serving (the desktop app calling a
|
||||
# local backend) locks CORS to these.
|
||||
_TAURI_CORS_ORIGINS = (
|
||||
"tauri://localhost", # Linux/macOS Tauri webview
|
||||
"http://tauri.localhost", # Windows Tauri webview
|
||||
"http://localhost", # dev fallback
|
||||
"http://localhost:5173", # Tauri dev/Vite
|
||||
"http://127.0.0.1:5173", # Tauri dev/Vite fallback
|
||||
)
|
||||
|
||||
|
||||
def cors_origins_for_mode(*, api_only: bool, secure: bool) -> list[str]:
|
||||
"""Allowed CORS origins. Default is any-origin (["*"]); api-only locks down
|
||||
to the Tauri desktop app, except in secure mode where the API is published
|
||||
over Cloudflare and must stay reachable from remote browser origins."""
|
||||
if api_only and not secure:
|
||||
return list(_TAURI_CORS_ORIGINS)
|
||||
return ["*"]
|
||||
|
||||
|
||||
def apply_stdio_mcp_loopback_default(host: str, *, is_colab: bool = False) -> None:
|
||||
"""Default stdio MCP servers on when bound to loopback.
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ from pathlib import Path
|
|||
|
||||
import structlog
|
||||
|
||||
from utils.uv_path_safety import uv_safe_path
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
DISABLE_ENV_VAR = "UNSLOTH_DISABLE_MLX_AUTOREPAIR"
|
||||
|
|
@ -49,6 +51,56 @@ MLX_PACKAGES = tuple(f"{name}>={version}" for name, version in _MLX_MIN_VERSIONS
|
|||
_MLX_REINSTALL_ARGS = tuple(
|
||||
arg for name in _MLX_PACKAGE_NAMES for arg in ("--reinstall-package", name)
|
||||
)
|
||||
# Require pre-built wheels for the unattended self-heal. A source distribution's
|
||||
# PEP 517 build backend runs arbitrary code at install time, and this install is
|
||||
# default-on, resolver-driven, and runs before the post-install stack check can
|
||||
# reject anything. mlx/mlx-metal ship wheels only (no sdist on PyPI) and
|
||||
# mlx-lm/mlx-vlm publish py3-none-any wheels, so requiring wheels does not break a
|
||||
# healthy self-heal; if a wheel is genuinely unavailable the install fails and
|
||||
# Studio stays chat-only (the existing safe fallback) until `unsloth studio update`.
|
||||
_ONLY_BINARY_ARG = "--only-binary=:all:"
|
||||
# Allowlist of environment variables forwarded to the install subprocess. The
|
||||
# self-heal runs without confirmation on the default startup path, so it must not
|
||||
# hand resolver/build code the full Studio environment. Everything outside this
|
||||
# set is dropped, which excludes three dangerous classes by construction:
|
||||
# * secrets (HF_TOKEN, AWS_*, WANDB_API_KEY, ...) that a malicious wheel/sdist
|
||||
# build hook would otherwise read straight out of os.environ;
|
||||
# * package-source redirects (UV_INDEX*, UV_DEFAULT_INDEX, UV_FIND_LINKS,
|
||||
# PIP_INDEX_URL, ...) so a poisoned process env cannot silently repoint the
|
||||
# install at an attacker-controlled index/find-links;
|
||||
# * cache-dir redirects (UV_CACHE_DIR, XDG_CACHE_HOME) so a poisoned env cannot
|
||||
# point uv at an attacker-staged cache (cache poisoning / symlink writes). uv
|
||||
# falls back to its safe user-owned default cache, reused across runs anyway.
|
||||
# uv still honours on-disk config (uv.toml / pip.conf), so a corporate mirror
|
||||
# configured there keeps working; only process-env redirects are dropped. We set
|
||||
# UV_OVERRIDE ourselves in _mlx_install_env, so a poisoned one here is ignored.
|
||||
_MLX_ENV_ALLOWLIST = frozenset(
|
||||
{
|
||||
"PATH",
|
||||
"HOME",
|
||||
"USER",
|
||||
"LOGNAME",
|
||||
"TMPDIR",
|
||||
"TMP",
|
||||
"TEMP",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
# proxies + custom CA bundles so installs behind a corporate gateway work
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"NO_PROXY",
|
||||
"ALL_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"no_proxy",
|
||||
"all_proxy",
|
||||
"SSL_CERT_FILE",
|
||||
"SSL_CERT_DIR",
|
||||
"REQUESTS_CA_BUNDLE",
|
||||
"CURL_CA_BUNDLE",
|
||||
}
|
||||
)
|
||||
_REPAIR_TIMEOUT_S = 900
|
||||
|
||||
# Attempt at most once per process; success is sticky (mlx then imports and the
|
||||
|
|
@ -134,13 +186,22 @@ def _uv_install_cmd(*args: str) -> list[str] | None:
|
|||
|
||||
|
||||
def _mlx_install_env() -> dict[str, str]:
|
||||
"""Environment for the mlx install. Mirror the main installer
|
||||
(install_python_stack.py) by pointing UV_OVERRIDE at overrides-darwin-arm64.txt,
|
||||
which relaxes mlx-vlm/mlx-lm's transformers>=5 requirement to >=4.57.6. Without
|
||||
it, uv keeps the Studio transformers pin only by silently backtracking mlx-vlm
|
||||
to an old, unsupported version (uv honours UV_OVERRIDE; plain pip ignores it,
|
||||
so the transformers constraint below is the pip-path safety net)."""
|
||||
env = dict(os.environ)
|
||||
"""Minimal, allowlisted environment for the unattended mlx install.
|
||||
|
||||
The self-heal runs without confirmation on the default startup path, so it
|
||||
forwards only the variables uv genuinely needs (see _MLX_ENV_ALLOWLIST) instead
|
||||
of the full Studio environment: secrets and package-source redirects in
|
||||
os.environ are dropped so a malicious resolver-selected artifact cannot read
|
||||
Studio secrets or be steered to a hostile index.
|
||||
|
||||
Mirror the main installer (install_python_stack.py) by pointing UV_OVERRIDE at
|
||||
overrides-darwin-arm64.txt, which relaxes mlx-vlm/mlx-lm's transformers>=5
|
||||
requirement to >=4.57.6. Without it, uv keeps the Studio transformers pin only
|
||||
by silently backtracking mlx-vlm to an old, unsupported version (uv honours
|
||||
UV_OVERRIDE; plain pip ignores it, so the transformers constraint below is the
|
||||
pip-path safety net). We set UV_OVERRIDE ourselves, so a poisoned one in the
|
||||
process env is ignored."""
|
||||
env = {key: os.environ[key] for key in _MLX_ENV_ALLOWLIST if key in os.environ}
|
||||
override = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "requirements"
|
||||
|
|
@ -148,7 +209,8 @@ def _mlx_install_env() -> dict[str, str]:
|
|||
/ "overrides-darwin-arm64.txt"
|
||||
)
|
||||
if override.is_file():
|
||||
env.setdefault("UV_OVERRIDE", str(override))
|
||||
# uv truncates UV_OVERRIDE at the first space (issue #6503).
|
||||
env.setdefault("UV_OVERRIDE", uv_safe_path(override))
|
||||
return env
|
||||
|
||||
|
||||
|
|
@ -191,7 +253,13 @@ def attempt_mlx_repair(*, timeout: int = _REPAIR_TIMEOUT_S) -> bool:
|
|||
constraint_path = None
|
||||
try:
|
||||
constraint_args, constraint_path = _transformers_constraint_args()
|
||||
cmd = _uv_install_cmd("--upgrade", *_MLX_REINSTALL_ARGS, *constraint_args, *MLX_PACKAGES)
|
||||
cmd = _uv_install_cmd(
|
||||
"--upgrade",
|
||||
_ONLY_BINARY_ARG,
|
||||
*_MLX_REINSTALL_ARGS,
|
||||
*constraint_args,
|
||||
*MLX_PACKAGES,
|
||||
)
|
||||
if cmd is None:
|
||||
logger.warning(
|
||||
"MLX self-heal requires uv so Studio can apply dependency overrides; "
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"""Checkpoint scanning utilities for discovering training runs and checkpoints."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import structlog
|
||||
from loggers import get_logger
|
||||
from pathlib import Path
|
||||
|
|
@ -12,6 +13,22 @@ from utils.paths import outputs_root, resolve_output_dir
|
|||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_CHECKPOINT_STEP_RE = re.compile(r"^checkpoint-(\d+)$")
|
||||
|
||||
|
||||
def _checkpoint_step(checkpoint_name: str) -> Optional[int]:
|
||||
match = _CHECKPOINT_STEP_RE.fullmatch(checkpoint_name)
|
||||
if match is None:
|
||||
return None
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def _checkpoint_sort_key(checkpoint_path: Path) -> tuple[int, int, str]:
|
||||
step = _checkpoint_step(checkpoint_path.name)
|
||||
if step is not None:
|
||||
return (0, -step, checkpoint_path.name)
|
||||
return (1, 0, str(checkpoint_path))
|
||||
|
||||
|
||||
def _read_checkpoint_loss(checkpoint_path: Path) -> Optional[float]:
|
||||
"""Read loss from the last log_history entry of trainer_state.json, or None."""
|
||||
|
|
@ -37,8 +54,10 @@ def scan_checkpoints(
|
|||
Returns:
|
||||
[(model_name, [(display_name, checkpoint_path, loss), ...], metadata), ...]
|
||||
metadata keys (optional): base_model, peft_type, lora_rank.
|
||||
First checkpoint entry is the main adapter; its loss mirrors the last
|
||||
(highest-step) intermediate checkpoint.
|
||||
First checkpoint entry is the main adapter; its loss mirrors the latest
|
||||
(highest-step) intermediate checkpoint. Numbered checkpoints are sorted
|
||||
by numeric step descending; non-numbered checkpoint-* dirs keep the
|
||||
previous lexicographic directory order.
|
||||
"""
|
||||
models = []
|
||||
outputs_path = resolve_output_dir(outputs_dir)
|
||||
|
|
@ -103,18 +122,25 @@ def scan_checkpoints(
|
|||
checkpoints.append((item.name, str(item), None))
|
||||
|
||||
# Scan for intermediate checkpoints (checkpoint-N subdirs).
|
||||
for sub in sorted(item.iterdir()):
|
||||
valid_checkpoints = []
|
||||
for sub in item.iterdir():
|
||||
if not sub.is_dir() or not sub.name.startswith("checkpoint-"):
|
||||
continue
|
||||
sub_config = sub / "config.json"
|
||||
sub_adapter = sub / "adapter_config.json"
|
||||
if sub_config.exists() or sub_adapter.exists():
|
||||
loss = _read_checkpoint_loss(sub)
|
||||
checkpoints.append((sub.name, str(sub), loss))
|
||||
valid_checkpoints.append(sub)
|
||||
|
||||
# Assign the last checkpoint's loss to the main adapter entry.
|
||||
if len(checkpoints) > 1:
|
||||
last_checkpoint_loss = checkpoints[-1][2]
|
||||
intermediate_checkpoints = []
|
||||
for sub in sorted(valid_checkpoints, key = _checkpoint_sort_key):
|
||||
loss = _read_checkpoint_loss(sub)
|
||||
intermediate_checkpoints.append((sub.name, str(sub), loss))
|
||||
|
||||
checkpoints.extend(intermediate_checkpoints)
|
||||
|
||||
# Assign the latest checkpoint's loss to the main adapter entry.
|
||||
if intermediate_checkpoints:
|
||||
last_checkpoint_loss = intermediate_checkpoints[0][2]
|
||||
checkpoints[0] = (
|
||||
checkpoints[0][0],
|
||||
checkpoints[0][1],
|
||||
|
|
@ -133,3 +159,64 @@ def scan_checkpoints(
|
|||
except Exception as e:
|
||||
logger.error(f"Error scanning checkpoints: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _is_model_dir(path: Path) -> bool:
|
||||
return (path / "config.json").exists() or (path / "adapter_config.json").exists()
|
||||
|
||||
|
||||
def has_preview_model(output_dir: Optional[str]) -> bool:
|
||||
"""True when ``output_dir`` holds a previewable root model (what ``/p/{run}``
|
||||
resolves). A cancelled run keeps ``output_dir`` but saves no root adapter."""
|
||||
if not output_dir:
|
||||
return False
|
||||
path = Path(output_dir)
|
||||
return path.is_dir() and _is_model_dir(path)
|
||||
|
||||
|
||||
def preview_ref(output_dir: Optional[str]) -> Optional[str]:
|
||||
"""``/p`` ref (``run`` or ``run/checkpoint``) relative to outputs_root, or None.
|
||||
|
||||
Posix-joined so a nested output dir keeps a working link instead of collapsing
|
||||
to its basename. None when not previewable, outside outputs_root, or deeper than
|
||||
the two path segments the ``/p`` route matches (so the UI omits a dead link).
|
||||
"""
|
||||
if not has_preview_model(output_dir):
|
||||
return None
|
||||
try:
|
||||
rel = Path(output_dir).resolve().relative_to(outputs_root().resolve())
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
parts = rel.parts
|
||||
if not parts or len(parts) > 2:
|
||||
return None
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def resolve_preview_checkpoint(run: str, checkpoint: Optional[str] = None) -> Path:
|
||||
relative = run if not checkpoint else f"{run}/{checkpoint}"
|
||||
path = resolve_output_dir(relative)
|
||||
if not path.is_dir() or not _is_model_dir(path):
|
||||
raise FileNotFoundError(
|
||||
f"No trained checkpoint at '{relative}'. Check the run/checkpoint name (see GET /p)."
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def list_preview_targets(outputs_dir: str = str(outputs_root())) -> List[dict]:
|
||||
targets: List[dict] = []
|
||||
for run_name, checkpoints, metadata in scan_checkpoints(outputs_dir):
|
||||
for display_name, path, loss in checkpoints:
|
||||
is_latest = display_name == run_name
|
||||
checkpoint = None if is_latest else Path(path).name
|
||||
targets.append(
|
||||
{
|
||||
"run": run_name,
|
||||
"checkpoint": checkpoint,
|
||||
"ref": run_name if is_latest else f"{run_name}/{checkpoint}",
|
||||
"is_latest": is_latest,
|
||||
"loss": loss,
|
||||
"base_model": metadata.get("base_model"),
|
||||
}
|
||||
)
|
||||
return targets
|
||||
|
|
|
|||
66
studio/backend/utils/uv_path_safety.py
Normal file
66
studio/backend/utils/uv_path_safety.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Hand uv a space-free `-c`/`--override`/`-r` file path (issue #6503).
|
||||
|
||||
uv splits `-c`/`--override` (and UV_OVERRIDE) on whitespace, so a path with a
|
||||
space truncates. Windows uses the 8.3 short form; POSIX copies the file into a
|
||||
space-free temp dir (removed at exit). Falls back to the original path on error.
|
||||
Shared by install_python_stack and utils.mlx_repair.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
IS_WINDOWS = platform.system() == "Windows"
|
||||
|
||||
_UV_SAFE_PATH_TMPDIRS: list[str] = []
|
||||
|
||||
|
||||
@atexit.register
|
||||
def _cleanup_uv_safe_path_tmpdirs() -> None:
|
||||
while _UV_SAFE_PATH_TMPDIRS:
|
||||
shutil.rmtree(_UV_SAFE_PATH_TMPDIRS.pop(), ignore_errors = True)
|
||||
|
||||
|
||||
def uv_safe_path(path: object) -> str:
|
||||
s = str(path)
|
||||
if " " not in s:
|
||||
return s
|
||||
if IS_WINDOWS:
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
get_short = ctypes.windll.kernel32.GetShortPathNameW
|
||||
get_short.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD]
|
||||
get_short.restype = wintypes.DWORD
|
||||
buf = ctypes.create_unicode_buffer(32768)
|
||||
rc = get_short(s, buf, 32768)
|
||||
if 0 < rc < 32768 and " " not in buf.value:
|
||||
return buf.value
|
||||
except Exception:
|
||||
pass
|
||||
return s
|
||||
tmp_dir = None
|
||||
try:
|
||||
if not os.path.isfile(s):
|
||||
return s
|
||||
tmp_dir = tempfile.mkdtemp(prefix = "unsloth_uv_")
|
||||
if " " in tmp_dir: # e.g. TMPDIR itself has a space
|
||||
shutil.rmtree(tmp_dir, ignore_errors = True)
|
||||
return s
|
||||
dst = os.path.join(tmp_dir, (os.path.basename(s) or "uv_args.txt").replace(" ", "_"))
|
||||
shutil.copyfile(s, dst)
|
||||
_UV_SAFE_PATH_TMPDIRS.append(tmp_dir)
|
||||
tmp_dir = None
|
||||
return dst
|
||||
except Exception:
|
||||
if tmp_dir is not None: # don't leak the temp dir if the copy failed
|
||||
shutil.rmtree(tmp_dir, ignore_errors = True)
|
||||
return s
|
||||
328
studio/frontend/package-lock.json
generated
328
studio/frontend/package-lock.json
generated
|
|
@ -88,7 +88,7 @@
|
|||
"globals": "^17.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.55.0",
|
||||
"vite": "^8.0.1"
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
|
|
@ -1913,13 +1913,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
"@tybys/wasm-util": "^0.10.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
|
@ -2027,9 +2027,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.127.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
|
||||
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
|
||||
"version": "0.133.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
|
|
@ -5583,9 +5583,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -5599,9 +5599,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -5615,9 +5615,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -5631,9 +5631,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -5647,9 +5647,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
|
||||
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -5663,12 +5663,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5679,12 +5682,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5695,12 +5701,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5711,12 +5720,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5727,12 +5739,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5743,12 +5758,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5759,9 +5777,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -5775,9 +5793,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
|
||||
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
|
|
@ -5793,9 +5811,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -5809,9 +5827,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -13091,6 +13109,34 @@
|
|||
"points-on-curve": "0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
|
|
@ -13104,6 +13150,24 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss/node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/powershell-utils": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
|
||||
|
|
@ -13977,13 +14041,13 @@
|
|||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.127.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.17"
|
||||
"@oxc-project/types": "=0.133.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
|
|
@ -13992,27 +14056,27 @@
|
|||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
|
||||
"@rolldown/binding-android-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-x64": "1.0.3",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.3",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.3",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.3",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.3",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.3",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/roughjs": {
|
||||
|
|
@ -14275,52 +14339,6 @@
|
|||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/shadcn/node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/shadcn/node_modules/postcss": {
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/shadcn/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
|
|
@ -14786,9 +14804,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -15456,16 +15474,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.10",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
||||
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.10",
|
||||
"rolldown": "1.0.0-rc.17",
|
||||
"tinyglobby": "^0.2.16"
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "1.0.3",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
|
|
@ -15481,7 +15499,7 @@
|
|||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.0",
|
||||
"@vitejs/devtools": "^0.1.18",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
|
|
@ -15546,52 +15564,6 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/postcss": {
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/warning": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@
|
|||
"globals": "^17.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.55.0",
|
||||
"vite": "^8.0.1"
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@biomejs/biome@1.9.4": true,
|
||||
|
|
|
|||
|
|
@ -45,8 +45,11 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWebUpdateCheck } from "@/hooks/use-web-update-check";
|
||||
import {
|
||||
Archive03Icon,
|
||||
ArrowRight02Icon,
|
||||
BadgeInfoIcon,
|
||||
ChefHatIcon,
|
||||
CursorInfo02Icon,
|
||||
DashboardCircleIcon,
|
||||
|
|
@ -254,6 +257,19 @@ function NavItem({
|
|||
);
|
||||
}
|
||||
|
||||
// TEMP DEV override: preview the update card on installs with no real update
|
||||
// (e.g. an editable checkout). In the browser console run
|
||||
// `localStorage.setItem("unsloth_force_update_card", "1")` and reload. Remove
|
||||
// before merge.
|
||||
function devForceUpdateCard(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem("unsloth_force_update_card") === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
const t = useT();
|
||||
const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle();
|
||||
|
|
@ -266,6 +282,16 @@ export function AppSidebar() {
|
|||
const { togglePinned, isMobile, setOpenMobile } = useSidebar();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Web update detection: `webUpdate` is non-null only when the installed
|
||||
// (PyPI) version is behind the latest release, so the card is hidden by
|
||||
// default. `forceUpdateCard` is a TEMP dev override to preview it on installs
|
||||
// with no real update (e.g. an editable checkout); remove before merge.
|
||||
const { status: webUpdate } = useWebUpdateCheck();
|
||||
const [forceUpdateCard] = useState(devForceUpdateCard);
|
||||
const showUpdateCard = Boolean(webUpdate) || forceUpdateCard;
|
||||
const updateVersion =
|
||||
webUpdate?.latestVersion ?? (forceUpdateCard ? "0.0.0" : null);
|
||||
|
||||
// Auto-close mobile Sheet after navigation
|
||||
const closeMobileIfOpen = () => {
|
||||
if (isMobile) setOpenMobile(false);
|
||||
|
|
@ -429,6 +455,7 @@ export function AppSidebar() {
|
|||
chatOpen,
|
||||
trainOpen,
|
||||
runsOpen,
|
||||
pinnedOpen,
|
||||
isStudioRoute,
|
||||
]);
|
||||
|
||||
|
|
@ -1078,6 +1105,16 @@ export function AppSidebar() {
|
|||
<SidebarContent
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => syncScrollState(e.currentTarget)}
|
||||
// Collapsible groups animate their height; re-measure the fade once the
|
||||
// open/close animation settles, not on the (still-animating) state flip.
|
||||
onAnimationEnd={(e) => {
|
||||
if (
|
||||
e.animationName === "collapsible-down" ||
|
||||
e.animationName === "collapsible-up"
|
||||
) {
|
||||
syncScrollState(e.currentTarget);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
// pb-2 keeps the last row's rounded highlight clear of the
|
||||
// overflow clip edge so its bottom corners aren't shaved off.
|
||||
|
|
@ -1361,18 +1398,75 @@ export function AppSidebar() {
|
|||
)}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="relative group-data-[collapsible=icon]:px-0">
|
||||
<SidebarFooter
|
||||
className={cn(
|
||||
"relative pb-3 group-data-[collapsible=icon]:px-0",
|
||||
// Tighter top with the update card so the fade hugs it; fuller top
|
||||
// for the profile on its own.
|
||||
showUpdateCard ? "pt-1.5" : "pt-2.5",
|
||||
)}
|
||||
>
|
||||
{/* Fade above the profile box, shown only when there's more list below
|
||||
the fold; at the bottom (or short lists) it fades so the last row
|
||||
shows fully (Gemini-style). right-2 keeps it clear of the 8px scrollbar gutter. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute left-0 right-2 bottom-full h-10 bg-gradient-to-t from-[var(--sidebar)] to-[rgb(from_var(--sidebar)_r_g_b/0)] transition-opacity duration-200",
|
||||
"pointer-events-none absolute left-0 right-2 bottom-full bg-gradient-to-t from-[var(--sidebar)] to-[rgb(from_var(--sidebar)_r_g_b/0)] transition-opacity duration-200",
|
||||
// Shorter fade when the update card sits above the profile so the
|
||||
// list reads closer to it.
|
||||
showUpdateCard ? "h-3" : "h-10",
|
||||
canScrollDown ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<SidebarMenu>
|
||||
<SidebarMenu className="gap-3 group-data-[collapsible=icon]:gap-2.5">
|
||||
{/* Update affordance — shows only when a newer version is available. */}
|
||||
{showUpdateCard && (
|
||||
<SidebarMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("shell.updateAvailable")}
|
||||
onClick={() => {
|
||||
useSettingsDialogStore
|
||||
.getState()
|
||||
.openDialog("about", { scrollTarget: "about-updates" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
className="flex h-[44px] w-full items-center gap-[9px] rounded-[14px] border border-border/60 bg-transparent px-2 py-[3px] text-left transition-colors hover:bg-nav-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-data-[collapsible=icon]:mx-auto group-data-[collapsible=icon]:h-[34px] group-data-[collapsible=icon]:w-[34px] group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:rounded-full group-data-[collapsible=icon]:p-0"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-[32px] shrink-0 items-center justify-center group-data-[collapsible=icon]:size-full"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={BadgeInfoIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-[21px] text-nav-fg"
|
||||
/>
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col gap-px leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate font-heading text-[13.5px] font-semibold text-nav-fg">
|
||||
{t("shell.updateAvailable")}
|
||||
</span>
|
||||
{updateVersion && (
|
||||
<span className="truncate text-[11.5px] text-muted-foreground">
|
||||
v{updateVersion}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-auto flex size-[32px] shrink-0 items-center justify-center text-muted-foreground group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowRight02Icon}
|
||||
className="size-[17px]"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
@ -1394,11 +1488,16 @@ export function AppSidebar() {
|
|||
<span className="truncate text-[11.5px] tracking-nav text-muted-foreground">Unsloth</span>
|
||||
</div>
|
||||
{/* settings cog (replaces the up/down chevron) */}
|
||||
<HugeiconsIcon
|
||||
icon={Settings02Icon}
|
||||
strokeWidth={1.5}
|
||||
className="ml-auto !size-[18px] text-muted-foreground group-data-[collapsible=icon]:hidden"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-auto flex size-[32px] shrink-0 items-center justify-center text-muted-foreground group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Settings02Icon}
|
||||
strokeWidth={1.5}
|
||||
className="!size-[18px]"
|
||||
/>
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
|
|
|
|||
|
|
@ -570,6 +570,52 @@ function ModelRow({
|
|||
|
||||
// ── GGUF Variant Expander ────────────────────────────────────
|
||||
|
||||
function isValidGgufVariant(variant: unknown): variant is GgufVariantDetail {
|
||||
if (!variant || typeof variant !== "object") return false;
|
||||
const candidate = variant as Partial<GgufVariantDetail>;
|
||||
return (
|
||||
typeof candidate.filename === "string" &&
|
||||
candidate.filename.length > 0 &&
|
||||
typeof candidate.quant === "string" &&
|
||||
candidate.quant.length > 0 &&
|
||||
typeof candidate.size_bytes === "number" &&
|
||||
Number.isFinite(candidate.size_bytes) &&
|
||||
candidate.size_bytes >= 0 &&
|
||||
(candidate.downloaded === undefined ||
|
||||
typeof candidate.downloaded === "boolean")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeGgufVariantsResponse(res: {
|
||||
variants?: unknown;
|
||||
default_variant?: unknown;
|
||||
has_vision?: unknown;
|
||||
context_length?: unknown;
|
||||
} | null | undefined): {
|
||||
variants: GgufVariantDetail[];
|
||||
defaultVariant: string | null;
|
||||
hasVision: boolean;
|
||||
contextLength: number | null;
|
||||
} {
|
||||
const contextLength = res?.context_length;
|
||||
return {
|
||||
variants: (Array.isArray(res?.variants) ? res.variants : []).filter(
|
||||
isValidGgufVariant,
|
||||
),
|
||||
defaultVariant:
|
||||
typeof res?.default_variant === "string" && res.default_variant.length > 0
|
||||
? res.default_variant
|
||||
: null,
|
||||
hasVision: res?.has_vision === true,
|
||||
contextLength:
|
||||
typeof contextLength === "number" &&
|
||||
Number.isFinite(contextLength) &&
|
||||
contextLength >= 0
|
||||
? contextLength
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function GgufVariantExpander({
|
||||
repoId,
|
||||
onSelect,
|
||||
|
|
@ -622,11 +668,12 @@ function GgufVariantExpander({
|
|||
listGgufVariants(repoId)
|
||||
.then((res) => {
|
||||
if (canceled) return;
|
||||
setVariants(res.variants);
|
||||
setDefaultVariant(res.default_variant);
|
||||
setHasVision(res.has_vision);
|
||||
onHasVision?.(res.has_vision);
|
||||
setNativeContext(res.context_length ?? null);
|
||||
const normalized = normalizeGgufVariantsResponse(res);
|
||||
setVariants(normalized.variants);
|
||||
setDefaultVariant(normalized.defaultVariant);
|
||||
setHasVision(normalized.hasVision);
|
||||
onHasVision?.(normalized.hasVision);
|
||||
setNativeContext(normalized.contextLength);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (canceled) return;
|
||||
|
|
@ -694,19 +741,25 @@ function GgufVariantExpander({
|
|||
// If the recommended variant is OOM, pick the largest fitting one;
|
||||
// if all are OOM, recommend the smallest.
|
||||
const effectiveRecommended = useMemo(() => {
|
||||
if (!variants || totalBudgetGb <= 0) return defaultVariant;
|
||||
if (!variants || variants.length === 0 || totalBudgetGb <= 0) {
|
||||
return defaultVariant;
|
||||
}
|
||||
const defaultV = variants.find((v) => v.quant === defaultVariant);
|
||||
if (defaultV && getGgufFit(defaultV.size_bytes) !== "oom")
|
||||
return defaultVariant;
|
||||
// Largest non-OOM variant (best quality that fits)
|
||||
const fitting = variants.filter((v) => getGgufFit(v.size_bytes) !== "oom");
|
||||
const fitting = variants.filter(
|
||||
(v) => getGgufFit(v.size_bytes) !== "oom",
|
||||
);
|
||||
if (fitting.length > 0) {
|
||||
fitting.sort((a, b) => b.size_bytes - a.size_bytes);
|
||||
return fitting[0].quant;
|
||||
}
|
||||
// All OOM -- recommend smallest (most likely to partially run)
|
||||
const sorted = [...variants].sort((a, b) => a.size_bytes - b.size_bytes);
|
||||
return sorted[0].quant;
|
||||
const sorted = [...variants].sort(
|
||||
(a, b) => a.size_bytes - b.size_bytes,
|
||||
);
|
||||
return sorted[0]?.quant ?? defaultVariant;
|
||||
}, [variants, defaultVariant, totalBudgetGb, getGgufFit]);
|
||||
|
||||
const sortedVariants = useMemo(() => {
|
||||
|
|
@ -2336,7 +2389,7 @@ export function HubModelPicker({
|
|||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search models"
|
||||
placeholder="Search Unsloth models"
|
||||
data-model-picker-search-input={true}
|
||||
className="field-soft h-9 border-0 pl-8 pr-8"
|
||||
/>
|
||||
|
|
@ -2345,15 +2398,20 @@ export function HubModelPicker({
|
|||
)}
|
||||
</div>
|
||||
{onBrowseHub ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBrowseHub}
|
||||
aria-label="Search more models on the Hub"
|
||||
className="hub-tab-toggle-pill flex h-9 w-[110px] shrink-0 items-center justify-center gap-[5px] rounded-full border-0 text-xs text-foreground transition-colors"
|
||||
>
|
||||
<HugeiconsIcon icon={DashboardCircleIcon} className="size-4" />
|
||||
Search Hub
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBrowseHub}
|
||||
aria-label="Search more models on the Hub"
|
||||
className="hub-tab-toggle-pill flex h-9 w-[110px] shrink-0 items-center justify-center gap-[5px] rounded-full border-0 text-xs text-foreground transition-colors"
|
||||
>
|
||||
<HugeiconsIcon icon={DashboardCircleIcon} className="size-4" />
|
||||
Search Hub
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Search all models</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
|
@ -2386,7 +2444,7 @@ export function HubModelPicker({
|
|||
// Height tracks the content up to the cap, so short lists do not
|
||||
// leave white space. scroll-py + symmetric px keep the focus ring off
|
||||
// the overflow clip edges during keyboard nav.
|
||||
"model-list-scroll max-h-[21rem] overflow-y-auto scroll-py-1.5 px-0.5 mr-1",
|
||||
"model-list-scroll max-h-[335px] overflow-y-auto scroll-py-1.5 px-0.5 mr-1",
|
||||
listScrolled && "is-scrolled",
|
||||
listMoreBelow && "is-bottom-faded",
|
||||
)}
|
||||
|
|
@ -2896,7 +2954,7 @@ export function HubModelPicker({
|
|||
}
|
||||
}}
|
||||
onArrowDownIntoChildren={
|
||||
isGgufExpanded(m.id)
|
||||
isGguf && !isDirectGguf && isGgufExpanded(m.id)
|
||||
? () => {
|
||||
const focused =
|
||||
focusFirstChildOption(optionKey);
|
||||
|
|
@ -2906,7 +2964,7 @@ export function HubModelPicker({
|
|||
}
|
||||
vramStatus={null}
|
||||
/>
|
||||
{isGgufExpanded(m.id) && (
|
||||
{isGguf && !isDirectGguf && isGgufExpanded(m.id) && (
|
||||
<GgufVariantExpander
|
||||
repoId={m.id}
|
||||
onDevice={true}
|
||||
|
|
@ -2983,7 +3041,7 @@ export function HubModelPicker({
|
|||
}
|
||||
}}
|
||||
onArrowDownIntoChildren={
|
||||
!isGgufFile && isGgufExpanded(m.id)
|
||||
isGguf && !isGgufFile && isGgufExpanded(m.id)
|
||||
? () => {
|
||||
const focused =
|
||||
focusFirstChildOption(optionKey);
|
||||
|
|
@ -2993,7 +3051,7 @@ export function HubModelPicker({
|
|||
}
|
||||
vramStatus={null}
|
||||
/>
|
||||
{!isGgufFile && isGgufExpanded(m.id) && (
|
||||
{isGguf && !isGgufFile && isGgufExpanded(m.id) && (
|
||||
<GgufVariantExpander
|
||||
repoId={m.id}
|
||||
onDevice={true}
|
||||
|
|
@ -3064,13 +3122,13 @@ export function HubModelPicker({
|
|||
}
|
||||
}}
|
||||
onArrowDownIntoChildren={
|
||||
!isGgufFile && isGgufExpanded(m.id)
|
||||
isGguf && !isGgufFile && isGgufExpanded(m.id)
|
||||
? () => focusFirstChildOption(optionKey)
|
||||
: undefined
|
||||
}
|
||||
vramStatus={null}
|
||||
/>
|
||||
{!isGgufFile && isGgufExpanded(m.id) && (
|
||||
{isGguf && !isGgufFile && isGgufExpanded(m.id) && (
|
||||
<GgufVariantExpander
|
||||
repoId={m.id}
|
||||
onDevice={true}
|
||||
|
|
@ -3362,7 +3420,7 @@ export function HubModelPicker({
|
|||
{/* Floating eject pill: overlaid on the list bottom, outside the scroll
|
||||
so the edge fade never touches it. Only the pill catches clicks. */}
|
||||
{onEject ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-end pr-3.5 pb-5">
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-end pr-3.5 pb-[19px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEject}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,21 @@ export interface RememberedLoadSettings {
|
|||
tensorParallel: boolean;
|
||||
}
|
||||
|
||||
// Storage key for a pick's remembered settings. The remembered knobs are
|
||||
// VRAM-budget driven (context override, KV-cache dtype, tensor-parallel), so the
|
||||
// right values differ per quant. An HF repo collapses all its GGUF variants into
|
||||
// one `id`, so fold the variant in to scope settings per quant. Local .gguf
|
||||
// paths key by their file path (already file-specific); native drag-drop files
|
||||
// key by display label, so same-named files in different folders share an entry.
|
||||
export function rememberedLoadSettingsKey(selection: {
|
||||
id: string;
|
||||
ggufVariant?: string | null;
|
||||
}): string {
|
||||
return selection.ggufVariant
|
||||
? `${selection.id}::${selection.ggufVariant}`
|
||||
: selection.id;
|
||||
}
|
||||
|
||||
function readAll(): Record<string, RememberedLoadSettings> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEY) ?? "{}");
|
||||
|
|
@ -31,24 +46,24 @@ function writeAll(all: Record<string, RememberedLoadSettings>) {
|
|||
}
|
||||
|
||||
export function loadRememberedLoadSettings(
|
||||
modelId: string,
|
||||
key: string,
|
||||
): RememberedLoadSettings | null {
|
||||
return readAll()[modelId] ?? null;
|
||||
return readAll()[key] ?? null;
|
||||
}
|
||||
|
||||
export function saveRememberedLoadSettings(
|
||||
modelId: string,
|
||||
key: string,
|
||||
settings: RememberedLoadSettings,
|
||||
) {
|
||||
const all = readAll();
|
||||
all[modelId] = settings;
|
||||
all[key] = settings;
|
||||
writeAll(all);
|
||||
}
|
||||
|
||||
export function clearRememberedLoadSettings(modelId: string) {
|
||||
export function clearRememberedLoadSettings(key: string) {
|
||||
const all = readAll();
|
||||
if (modelId in all) {
|
||||
delete all[modelId];
|
||||
if (key in all) {
|
||||
delete all[key];
|
||||
writeAll(all);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ import {
|
|||
} from "@assistant-ui/react";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { ChevronDownIcon, CopyIcon, LightbulbIcon } from "lucide-react";
|
||||
import { ChevronDownIcon, CopyIcon } from "lucide-react";
|
||||
import { BulbIcon } from "@/lib/bulb-icon";
|
||||
import { Tick02Icon } from "@/lib/tick-icon";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
|
|
@ -128,7 +129,7 @@ function ReasoningTrigger({
|
|||
)}
|
||||
{...props}
|
||||
>
|
||||
<LightbulbIcon className="aui-reasoning-trigger-icon size-4 shrink-0" />
|
||||
<BulbIcon className="aui-reasoning-trigger-icon size-4 shrink-0" />
|
||||
<span
|
||||
data-slot="reasoning-trigger-label"
|
||||
className="aui-reasoning-trigger-label-wrapper relative inline-block leading-none"
|
||||
|
|
@ -393,7 +394,8 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({
|
|||
<ReasoningTrigger
|
||||
className="min-w-0 flex-1"
|
||||
active={isReasoningStreaming}
|
||||
duration={duration || persistedDuration}
|
||||
// Prefer server timing when available.
|
||||
duration={persistedDuration || duration}
|
||||
/>
|
||||
<div className="flex w-16 shrink-0 justify-end">
|
||||
{isOpen && !isReasoningStreaming && (
|
||||
|
|
|
|||
|
|
@ -3043,6 +3043,7 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
|
|||
type="button"
|
||||
aria-label="Tools and attachments"
|
||||
className="unsloth-composer-plus"
|
||||
data-tour="chat-plus-menu"
|
||||
>
|
||||
<PlusIcon className="size-[22px] stroke-[1.75px]" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -2627,6 +2627,17 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Local GGUF sends server-timed reasoning duration. Guard the type
|
||||
// so a malformed or proxied chunk (string/null/NaN duration) can
|
||||
// never turn the label into NaN.
|
||||
const reasoningMs = (
|
||||
chunk as { _reasoningDurationMs?: number } | null | undefined
|
||||
)?._reasoningDurationMs;
|
||||
if (typeof reasoningMs === "number" && Number.isFinite(reasoningMs)) {
|
||||
reasoningDuration = Math.max(0, Math.round(reasoningMs / 1000));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Diffusion frame: a transient canvas snapshot. Route it to the transient
|
||||
// store (the in-bubble renderer reads it) and skip it; it has no assistant
|
||||
// text, so it never enters the transcript or the counters below.
|
||||
|
|
@ -3175,6 +3186,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
}
|
||||
const textParts = parseAssistantContent(cumulativeText);
|
||||
|
||||
// Fallback when no server-side reasoning_summary arrives.
|
||||
if (
|
||||
textParts.some((part) => part.type === "reasoning") &&
|
||||
!reasoningStartAt
|
||||
|
|
@ -3283,6 +3295,13 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
finalTokPerSec,
|
||||
);
|
||||
|
||||
// Finalize reasoning-only streams.
|
||||
if (reasoningStartAt && !reasoningDuration) {
|
||||
reasoningDuration = Math.max(
|
||||
0,
|
||||
Math.round((Date.now() - reasoningStartAt) / 1000),
|
||||
);
|
||||
}
|
||||
yield {
|
||||
content: [
|
||||
...buildAssistantContent(cumulativeText),
|
||||
|
|
|
|||
|
|
@ -902,6 +902,19 @@ export async function* streamChatCompletions(
|
|||
separatorIndex = buffer.search(/\r?\n\r?\n/);
|
||||
continue;
|
||||
}
|
||||
// Relay server-side reasoning duration.
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"type" in parsed &&
|
||||
parsed.type === "reasoning_summary"
|
||||
) {
|
||||
yield {
|
||||
_reasoningDurationMs: (parsed as { duration_ms?: number }).duration_ms,
|
||||
} as unknown as OpenAIChatChunk;
|
||||
separatorIndex = buffer.search(/\r?\n\r?\n/);
|
||||
continue;
|
||||
}
|
||||
yield parsed as OpenAIChatChunk;
|
||||
separatorIndex = buffer.search(/\r?\n\r?\n/);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import {
|
|||
type ModelOption,
|
||||
ModelSelector,
|
||||
} from "@/components/assistant-ui/model-selector";
|
||||
import {
|
||||
loadRememberedLoadSettings,
|
||||
rememberedLoadSettingsKey,
|
||||
} from "@/components/assistant-ui/model-selector/remembered-load-settings";
|
||||
import { ProjectComposer, Thread } from "@/components/assistant-ui/thread";
|
||||
import { CopyableErrorChip } from "@/components/ui/copyable-error-chip";
|
||||
import {
|
||||
|
|
@ -1275,13 +1279,18 @@ export function ChatPage({
|
|||
selectModelRef.current = selectModel;
|
||||
}, [refresh, selectModel]);
|
||||
// Load a cached autoLoad pick once its download finishes. The sheet was never
|
||||
// opened, so on a load failure just drop the orphaned staged knobs.
|
||||
// opened, so on a load failure just drop the orphaned staged knobs. The knobs
|
||||
// were already seeded on stage, so keepSpeculative only when a config was
|
||||
// saved -- otherwise the standing speculative preference should win.
|
||||
autoLoadStagedRef.current = (pending) => {
|
||||
const remembered = loadRememberedLoadSettings(
|
||||
rememberedLoadSettingsKey(pending),
|
||||
);
|
||||
void selectModel({
|
||||
...pending,
|
||||
isDownloaded: true,
|
||||
forceReload: true,
|
||||
keepSpeculative: false,
|
||||
keepSpeculative: remembered != null,
|
||||
throwOnError: true,
|
||||
}).catch(() => {
|
||||
const store = useChatRuntimeStore.getState();
|
||||
|
|
@ -1648,10 +1657,21 @@ export function ChatPage({
|
|||
(!hasGgufSource(selection) && !wantManagerDownload) ||
|
||||
(store.loadOnSelection && selection.isDownloaded)
|
||||
) {
|
||||
// Detach any staged pick first so its edited knobs don't leak into this
|
||||
// immediate load. Detach (not abandon) keeps its download running.
|
||||
// Detach any staged pick first so its edited knobs (e.g. a custom
|
||||
// context length) don't leak into this immediate load -- resolveLoad
|
||||
// reads customContextLength before checking the target is GGUF. Detach
|
||||
// (not abandon) keeps its download running.
|
||||
detachStaged();
|
||||
await selectModel(selection);
|
||||
// Load-on-selection skips the sheet, so seed the saved knobs here the
|
||||
// way the sheet's restore effect would; the switch would otherwise reset
|
||||
// the remembered speculative choice (keepSpeculative below prevents it).
|
||||
const remembered = hasGgufSource(selection)
|
||||
? loadRememberedLoadSettings(rememberedLoadSettingsKey(selection))
|
||||
: null;
|
||||
if (remembered) store.applyRememberedLoadSettings(remembered);
|
||||
await selectModel(
|
||||
remembered ? { ...selection, keepSpeculative: true } : selection,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Loads can't queue behind each other, but a download is independent: if
|
||||
|
|
@ -2506,7 +2526,6 @@ export function ChatPage({
|
|||
onClick={() => setSettingsOpen(true)}
|
||||
className="flex h-[34px] w-[34px] translate-x-[2px] cursor-pointer items-center justify-center rounded-full 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 run settings"
|
||||
data-tour="chat-settings"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={LayoutAlignRightIcon}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||
import {
|
||||
clearRememberedLoadSettings,
|
||||
loadRememberedLoadSettings,
|
||||
rememberedLoadSettingsKey,
|
||||
saveRememberedLoadSettings,
|
||||
} from "@/components/assistant-ui/model-selector/remembered-load-settings";
|
||||
import {
|
||||
|
|
@ -579,6 +580,9 @@ export function ChatSettingsPanel({
|
|||
);
|
||||
const kvCacheDtype = useChatRuntimeStore((s) => s.kvCacheDtype);
|
||||
const setKvCacheDtype = useChatRuntimeStore((s) => s.setKvCacheDtype);
|
||||
const applyRememberedLoadSettings = useChatRuntimeStore(
|
||||
(s) => s.applyRememberedLoadSettings,
|
||||
);
|
||||
const loadedKvCacheDtype = useChatRuntimeStore((s) => s.loadedKvCacheDtype);
|
||||
const tensorParallel = useChatRuntimeStore((s) => s.tensorParallel);
|
||||
const setTensorParallel = useChatRuntimeStore((s) => s.setTensorParallel);
|
||||
|
|
@ -613,25 +617,16 @@ export function ChatSettingsPanel({
|
|||
// the saved per-model settings on stage, so the sheet opens with what was used
|
||||
// last time; the tick reflects whether a saved entry exists.
|
||||
const [remember, setRemember] = useState(false);
|
||||
const pendingId = pendingSelection?.id ?? null;
|
||||
// Keyed per quant: a different variant of the same repo has its own settings.
|
||||
const pendingKey = pendingSelection
|
||||
? rememberedLoadSettingsKey(pendingSelection)
|
||||
: null;
|
||||
useEffect(() => {
|
||||
if (!pendingId) return;
|
||||
const saved = loadRememberedLoadSettings(pendingId);
|
||||
if (!pendingKey) return;
|
||||
const saved = loadRememberedLoadSettings(pendingKey);
|
||||
setRemember(saved != null);
|
||||
if (!saved) return;
|
||||
setCustomContextLength(saved.contextLength);
|
||||
setKvCacheDtype(saved.kvCacheDtype);
|
||||
setSpeculativeType(saved.speculativeType ?? "auto");
|
||||
setSpecDraftNMax(saved.specDraftNMax);
|
||||
setTensorParallel(saved.tensorParallel);
|
||||
}, [
|
||||
pendingId,
|
||||
setCustomContextLength,
|
||||
setKvCacheDtype,
|
||||
setSpeculativeType,
|
||||
setSpecDraftNMax,
|
||||
setTensorParallel,
|
||||
]);
|
||||
if (saved) applyRememberedLoadSettings(saved);
|
||||
}, [pendingKey, applyRememberedLoadSettings]);
|
||||
// While staging, the sheet reflects the STAGED model, so its header context
|
||||
// takes precedence over the loaded model's (which may differ or be larger).
|
||||
const baseContext = pendingIsGguf ? stagedContextLength : ggufContextLength;
|
||||
|
|
@ -1213,9 +1208,11 @@ export function ChatSettingsPanel({
|
|||
type="button"
|
||||
onClick={() => {
|
||||
// Persist (or clear) this model's load knobs before loading.
|
||||
// Save the explicit context override only (null = auto), so
|
||||
// restoring never forces the native context into an OOM.
|
||||
const pid = pendingSelection?.id;
|
||||
// Context is stored as the override (null = auto), never the
|
||||
// resolved native value, so restoring can't force an OOM.
|
||||
const pid = pendingSelection
|
||||
? rememberedLoadSettingsKey(pendingSelection)
|
||||
: null;
|
||||
if (pid) {
|
||||
if (remember) {
|
||||
saveRememberedLoadSettings(pid, {
|
||||
|
|
@ -1775,7 +1772,9 @@ export function ChatSettingsPanel({
|
|||
<SheetTitle>Run settings</SheetTitle>
|
||||
<SheetDescription>Chat inference settings</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full flex-col">{settingsContent}</div>
|
||||
<div data-tour="chat-settings" className="flex h-full flex-col">
|
||||
{settingsContent}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
|
@ -1783,6 +1782,7 @@ export function ChatSettingsPanel({
|
|||
|
||||
return (
|
||||
<aside
|
||||
data-tour="chat-settings"
|
||||
className={`relative z-50 shrink-0 h-full overflow-hidden bg-panel-surface text-panel-surface-fg font-heading ${open ? "w-[17rem] border-l border-sidebar-border" : "w-0"}`}
|
||||
>
|
||||
<div className="h-full w-full">{settingsContent}</div>
|
||||
|
|
|
|||
|
|
@ -60,6 +60,11 @@ type McpPreset = {
|
|||
// Keyless remote MCP presets (rate-limited free tiers, no API key).
|
||||
// Hugging Face runs anonymously; add a token via "Manage MCP servers".
|
||||
const MCP_PRESETS: readonly McpPreset[] = [
|
||||
{
|
||||
id: "unsloth-docs",
|
||||
displayName: "Unsloth Docs",
|
||||
url: "https://unsloth.ai/docs/~gitbook/mcp",
|
||||
},
|
||||
{
|
||||
id: "context7",
|
||||
displayName: "Context7",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ import {
|
|||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
|
|
@ -300,33 +302,53 @@ export function ProjectsPage() {
|
|||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Export All Projects</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-52">
|
||||
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
|
||||
<DropdownMenuItem key={`ap-m-${fmt}`} onSelect={() => void handleBulkProjectExport("projects", fmt, true)}>
|
||||
{label} (combined)
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="pb-1 pt-2 text-[11px] font-medium">
|
||||
Combined
|
||||
</DropdownMenuLabel>
|
||||
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
|
||||
<DropdownMenuItem key={`ap-m-${fmt}`} onSelect={() => void handleBulkProjectExport("projects", fmt, true)}>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
|
||||
<DropdownMenuItem key={`ap-s-${fmt}`} onSelect={() => void handleBulkProjectExport("projects", fmt, false)}>
|
||||
{label} (per chat)
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="pb-1 pt-2 text-[11px] font-medium">
|
||||
Per chat
|
||||
</DropdownMenuLabel>
|
||||
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
|
||||
<DropdownMenuItem key={`ap-s-${fmt}`} onSelect={() => void handleBulkProjectExport("projects", fmt, false)}>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Export Projects + Recents</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-52">
|
||||
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
|
||||
<DropdownMenuItem key={`all-m-${fmt}`} onSelect={() => void handleBulkProjectExport("all", fmt, true)}>
|
||||
{label} (combined)
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="pb-1 pt-2 text-[11px] font-medium">
|
||||
Combined
|
||||
</DropdownMenuLabel>
|
||||
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
|
||||
<DropdownMenuItem key={`all-m-${fmt}`} onSelect={() => void handleBulkProjectExport("all", fmt, true)}>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
|
||||
<DropdownMenuItem key={`all-s-${fmt}`} onSelect={() => void handleBulkProjectExport("all", fmt, false)}>
|
||||
{label} (per chat)
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="pb-1 pt-2 text-[11px] font-medium">
|
||||
Per chat
|
||||
</DropdownMenuLabel>
|
||||
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
|
||||
<DropdownMenuItem key={`all-s-${fmt}`} onSelect={() => void handleBulkProjectExport("all", fmt, false)}>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
thinkToggleAriaLabel,
|
||||
} from "@/components/assistant-ui/think-aria-label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BulbIcon } from "@/lib/bulb-icon";
|
||||
import { Tick02Icon } from "@/lib/tick-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
|
|
@ -155,20 +156,6 @@ const MicIcon: FC<{ className?: string }> = ({ className }) => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
const BulbIcon: FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="-10.24 -10.24 1044.48 1044.48"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth={16.384}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden={true}
|
||||
>
|
||||
<path d="M511.984 0c-198.032 0-353.12 161.104-353.12 359.136 0 149.2 73.28 220.256 131.185 272.128 37.28 33.424 62.368 53.552 62.368 78.352v54.255c0 1.392.193 2.752.368 4.128h-.72v92.624c.016 97.712 63.2 163.376 161.072 163.376 94.464 0 158.944-65.664 158.944-163.376V768h-.928c.176-1.376.416-2.736.416-4.128v-54.255c0-37.76 28.032-60.592 70.528-97.696 57.504-50.208 123.023-112.688 123.023-252.784C865.136 161.104 710.016 0 511.983 0zm-1.215 960c-59.904 0-94.689-37.152-94.689-99.376l-.463-42.672C438.64 825.824 470 832 512 832c41.424 0 72.848-6.624 96.08-14.768v43.392c0 63.152-35.247 99.376-97.312 99.376zm189.248-396.288c-43.472 37.968-92.433 77.216-92.433 145.904v40.432c-15.183 8.48-43.183 18.56-96.127 18.56-55.569 0-81.92-9.856-95.024-17.473V709.6c0-54.608-42.688-89.297-83.68-126.017-54.32-48.672-109.873-103.84-109.873-224.464-.015-162.72 126.385-295.12 289.104-295.12 162.752 0 289.152 132.4 289.152 295.137 0 111.024-48.463 158.576-101.12 204.576z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
function isNativeComposing(event: Event) {
|
||||
return "isComposing" in event && (event as InputEvent).isComposing === true;
|
||||
}
|
||||
|
|
@ -625,6 +612,12 @@ export function SharedComposer({
|
|||
const isEffort =
|
||||
effectiveReasoningStyle === "reasoning_effort" ||
|
||||
effectiveReasoningStyle === "enable_thinking_effort";
|
||||
// GLM-5.2's effort menu (Off, high, max) has short rows, so it can sit a
|
||||
// touch skinnier. Skip the narrower floor when a Preserve thinking row is
|
||||
// present, since that longer label needs the wider width to stay one line.
|
||||
const narrowEffortMenu =
|
||||
effectiveReasoningStyle === "enable_thinking_effort" &&
|
||||
!supportsPreserveThinking;
|
||||
const thinkingActiveLook = isEffort
|
||||
? reasoningLockedOn || (effectiveReasoningVisualEnabled && !reasoningDisabled)
|
||||
: reasoningLockedOn || (effectiveReasoningEnabled && !reasoningDisabled);
|
||||
|
|
@ -1798,7 +1791,10 @@ export function SharedComposer({
|
|||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="end"
|
||||
className="unsloth-plus-menu min-w-44"
|
||||
className={cn(
|
||||
"unsloth-plus-menu",
|
||||
narrowEffortMenu ? "min-w-40" : "min-w-44",
|
||||
)}
|
||||
>
|
||||
{isEffort ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import type { RememberedLoadSettings } from "@/components/assistant-ui/model-selector/remembered-load-settings";
|
||||
import { cancelStagedModelDownload } from "@/features/hub";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { create } from "zustand";
|
||||
|
|
@ -770,6 +771,10 @@ type ChatRuntimeStore = {
|
|||
* start each deferred-staging session clean so one staged pick's settings
|
||||
* don't leak onto the next. */
|
||||
resetModelSettingsToLoaded: () => void;
|
||||
/** Seed the editable load knobs from a model's remembered settings. Shared by
|
||||
* the settings sheet's restore effect and the "Load on selection" paths,
|
||||
* which skip the sheet but must still honor a saved config. */
|
||||
applyRememberedLoadSettings: (settings: RememberedLoadSettings) => void;
|
||||
setTensorParallel: (value: boolean) => void;
|
||||
setLoadOnSelection: (value: boolean) => void;
|
||||
setExpandQuantizations: (value: boolean) => void;
|
||||
|
|
@ -1528,6 +1533,16 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
setSpecDraftNMax: (specDraftNMax) => set({ specDraftNMax }),
|
||||
setTensorParallel: (tensorParallel) => set({ tensorParallel }),
|
||||
resetModelSettingsToLoaded: () => set((s) => loadedBaselineSettings(s)),
|
||||
applyRememberedLoadSettings: (settings) =>
|
||||
// Coalesce every field: a blob persisted by an older/newer build can omit
|
||||
// keys, and a raw spread would push `undefined` into fields typed non-null.
|
||||
set({
|
||||
customContextLength: settings.contextLength ?? null,
|
||||
kvCacheDtype: settings.kvCacheDtype ?? null,
|
||||
speculativeType: settings.speculativeType ?? "auto",
|
||||
specDraftNMax: settings.specDraftNMax ?? null,
|
||||
tensorParallel: settings.tensorParallel ?? false,
|
||||
}),
|
||||
setLoadOnSelection: (loadOnSelection) => {
|
||||
saveBool(CHAT_LOAD_ON_SELECTION_KEY, loadOnSelection);
|
||||
set({ loadOnSelection });
|
||||
|
|
|
|||
|
|
@ -27,20 +27,21 @@ export function buildChatTourSteps({
|
|||
title: "Pick a model",
|
||||
body: (
|
||||
<>
|
||||
This selects what’s loaded for inference. Hub = base models. Fine-tuned
|
||||
= trained Unsloth outputs, including LoRA adapters and full finetunes.
|
||||
Selects what’s loaded for inference. Recommended is Unsloth’s curated
|
||||
base models; On Device is your downloads and fine-tuned outputs (LoRA
|
||||
adapters and full finetunes).
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "model-tabs",
|
||||
target: "chat-model-selector-popover",
|
||||
title: "Two tabs",
|
||||
title: "Find a model",
|
||||
body: (
|
||||
<>
|
||||
Hub: search Hugging Face models. Fine-tuned: local Unsloth outputs you’ve
|
||||
trained or exported. If results look off, compare base vs fine-tuned
|
||||
outputs to see what changed.
|
||||
Search Unsloth’s models, or hit Search Hub for all of Hugging Face.
|
||||
Switch Recommended and On Device, filter by format, and sort by
|
||||
trending or recent. An OOM tag means it won’t fit in your VRAM.
|
||||
</>
|
||||
),
|
||||
onEnter: openModelSelector,
|
||||
|
|
@ -59,6 +60,18 @@ export function buildChatTourSteps({
|
|||
onEnter: openSettings,
|
||||
onExit: closeSettings,
|
||||
},
|
||||
{
|
||||
id: "plus-menu",
|
||||
target: "chat-plus-menu",
|
||||
title: "The + menu",
|
||||
body: (
|
||||
<>
|
||||
Everything else lives here: attach photos and files, reuse saved
|
||||
prompts, toggle tools and MCP, start a side-by-side compare, and
|
||||
export the chat.
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (canCompare) {
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ export const ModelsToolbar = memo(function ModelsToolbar({
|
|||
? `Search on-device ${isDataset ? "datasets" : "models"}`
|
||||
: isDataset
|
||||
? "Search datasets"
|
||||
: "Search models"
|
||||
: "Search all models"
|
||||
}
|
||||
className={cn(
|
||||
"field-soft h-9 rounded-full !border-0 pl-10 text-[13px] placeholder:text-muted-foreground/80 focus-visible:!ring-0",
|
||||
|
|
|
|||
|
|
@ -19,24 +19,6 @@ const SIZES: Record<AvatarSize, string> = {
|
|||
lg: "size-12 rounded-[15px] text-[16px]",
|
||||
};
|
||||
|
||||
// Unsloth's own uploads (no upstream provider match) show the bundled Unsloth
|
||||
// brand avatar instead of a colored-initial tile, so they read as Unsloth even
|
||||
// in virtualized rows that never fetch the HF profile picture.
|
||||
const UNSLOTH_OWNER_LOGO: ProviderLogo = {
|
||||
id: "unsloth",
|
||||
name: "Unsloth",
|
||||
logoPath: "/circle-logo-small.png",
|
||||
treatment: "original",
|
||||
background: "transparent",
|
||||
fit: "cover",
|
||||
// Used directly for the unsloth owner, never via prefix matching.
|
||||
prefixes: [],
|
||||
};
|
||||
|
||||
function isUnslothOwner(owner: string): boolean {
|
||||
return owner.trim().toLowerCase() === "unsloth";
|
||||
}
|
||||
|
||||
const AVATAR_IMAGE_RETRY_BASE_MS = 60_000;
|
||||
const AVATAR_IMAGE_RETRY_MAX_MS = 30 * 60_000;
|
||||
|
||||
|
|
@ -86,15 +68,6 @@ export function OwnerAvatar({
|
|||
/>
|
||||
);
|
||||
}
|
||||
if (isUnslothOwner(owner)) {
|
||||
return (
|
||||
<ProviderLogoTile
|
||||
provider={UNSLOTH_OWNER_LOGO}
|
||||
size={size}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DefaultAvatar
|
||||
owner={owner}
|
||||
|
|
@ -114,9 +87,6 @@ export function useAvatarImageUrl(
|
|||
if (provider) {
|
||||
return provider.treatment === "original" ? provider.logoPath : null;
|
||||
}
|
||||
if (isUnslothOwner(owner)) {
|
||||
return UNSLOTH_OWNER_LOGO.logoPath;
|
||||
}
|
||||
return remoteUrl;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ export function OwnerScopeToggle({
|
|||
onValueChange={onChange}
|
||||
ariaLabel="Publisher scope"
|
||||
align="end"
|
||||
// Extra gap so the chevron sits a touch further from the label.
|
||||
className="h-8 gap-1.5 text-[11.5px]"
|
||||
// Extra gap before the chevron; min-width keeps the pill readable.
|
||||
className="h-8 min-w-[96px] gap-1.5 text-[11.5px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
// 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 {
|
||||
loadRememberedLoadSettings,
|
||||
rememberedLoadSettingsKey,
|
||||
} from "@/components/assistant-ui/model-selector/remembered-load-settings";
|
||||
import { useHubInventory } from "@/features/hub/inventory";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useGpuInfo } from "@/hooks/use-gpu-info";
|
||||
|
|
@ -14,7 +18,10 @@ import { useHubInfiniteScroll } from "@/features/hub/hooks/use-hub-infinite-scro
|
|||
import { ggufVariantsMatch, modelIdsMatch } from "@/features/hub/lib/model-identity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { useHfTokenStore } from "@/features/hub/stores/hf-token-store";
|
||||
import {
|
||||
hfApiToken,
|
||||
useHfTokenStore,
|
||||
} from "@/features/hub/stores/hf-token-store";
|
||||
import {
|
||||
getInferenceStatus,
|
||||
isExternalModelId,
|
||||
|
|
@ -90,18 +97,19 @@ const ALL_MODELS_VIEW_STORAGE_KEY = "unsloth.hub.allModelsView";
|
|||
const INVENTORY_SORT_STORAGE_KEY = "unsloth.hub.inventorySort";
|
||||
const OWNER_SCOPE_STORAGE_KEY = "unsloth.hub.ownerScope";
|
||||
|
||||
/** Discover browsing scope: only the unsloth org (default) or the whole Hub. */
|
||||
/** Discover browsing scope: the whole Hub (default) or only the unsloth org. */
|
||||
export type OwnerScope = "unsloth" | "all";
|
||||
|
||||
function readOwnerScopePreference(): OwnerScope {
|
||||
if (typeof window === "undefined") {
|
||||
return "unsloth";
|
||||
return "all";
|
||||
}
|
||||
try {
|
||||
const value = window.localStorage.getItem(OWNER_SCOPE_STORAGE_KEY);
|
||||
return value === "all" ? "all" : "unsloth";
|
||||
// Default to the whole Hub; only honor an explicit "unsloth" preference.
|
||||
return value === "unsloth" ? "unsloth" : "all";
|
||||
} catch {
|
||||
return "unsloth";
|
||||
return "all";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -550,6 +558,7 @@ export function ModelsPage() {
|
|||
const deferredDebouncedQuery = useDeferredValue(debouncedQuery);
|
||||
const hfToken = useHfTokenStore((s) => s.token);
|
||||
const debouncedHfToken = useDebouncedValue(hfToken, 500);
|
||||
const apiHfToken = hfApiToken(debouncedHfToken);
|
||||
const deferredFormatFilter = useDeferredValue(formatFilter);
|
||||
const deferredCapabilityFilter = useDeferredValue(capabilityFilter);
|
||||
|
||||
|
|
@ -604,7 +613,7 @@ export function ModelsPage() {
|
|||
handleRetrySearch,
|
||||
} = useDiscoverSearch({
|
||||
debouncedQuery,
|
||||
accessToken: debouncedHfToken || undefined,
|
||||
accessToken: apiHfToken,
|
||||
isDiscoverTab,
|
||||
isDatasetMode,
|
||||
sortBy: effectiveSort,
|
||||
|
|
@ -618,7 +627,7 @@ export function ModelsPage() {
|
|||
channelId: isChannelListMode ? activeChannelId : null,
|
||||
results,
|
||||
isLoading,
|
||||
accessToken: debouncedHfToken || undefined,
|
||||
accessToken: apiHfToken,
|
||||
});
|
||||
|
||||
const {
|
||||
|
|
@ -701,7 +710,7 @@ export function ModelsPage() {
|
|||
const listRows = filteredDiscoverRows;
|
||||
|
||||
const hubFeed = useHubFeed({
|
||||
accessToken: debouncedHfToken || undefined,
|
||||
accessToken: apiHfToken,
|
||||
online,
|
||||
enabled: isFeedMode,
|
||||
deviceType,
|
||||
|
|
@ -907,7 +916,7 @@ export function ModelsPage() {
|
|||
filteredCachedRows,
|
||||
filteredLocalRows,
|
||||
results: selectionResults,
|
||||
accessToken: debouncedHfToken || undefined,
|
||||
accessToken: apiHfToken,
|
||||
online,
|
||||
});
|
||||
|
||||
|
|
@ -1083,11 +1092,32 @@ export function ModelsPage() {
|
|||
openNewChat();
|
||||
return;
|
||||
}
|
||||
// Detach any leftover staged pick first so its edited knobs (e.g. a custom
|
||||
// context length) don't leak into this load -- mirrors the chat page's
|
||||
// detachStaged(); keepDownload keeps any staged download running.
|
||||
useChatRuntimeStore.getState().abandonStagedModel({ keepDownload: true });
|
||||
// Load-on-selection skips the chat sheet, so seed this GGUF pick's saved
|
||||
// load knobs here the way the sheet's restore effect would; otherwise the
|
||||
// remembered config is silently ignored on the Hub run path. keepSpeculative
|
||||
// then honors the restored speculative choice across the switch.
|
||||
const remembered =
|
||||
opts.ggufVariant != null || selectedModel.isGguf
|
||||
? loadRememberedLoadSettings(
|
||||
rememberedLoadSettingsKey({
|
||||
id: runId,
|
||||
ggufVariant: opts.ggufVariant,
|
||||
}),
|
||||
)
|
||||
: null;
|
||||
if (remembered) {
|
||||
useChatRuntimeStore.getState().applyRememberedLoadSettings(remembered);
|
||||
}
|
||||
void selectModel({
|
||||
id: runId,
|
||||
ggufVariant: opts.ggufVariant,
|
||||
isDownloaded,
|
||||
expectedBytes: opts.expectedBytes,
|
||||
keepSpeculative: remembered != null,
|
||||
throwOnError: true,
|
||||
})
|
||||
.then(() => {
|
||||
|
|
|
|||
|
|
@ -116,3 +116,11 @@ export const useHfTokenStore = create<HfTokenStore>((set) => {
|
|||
export function getHfToken(): string {
|
||||
return useHfTokenStore.getState().token;
|
||||
}
|
||||
|
||||
// HF's JS client throws on a non-empty token that isn't `hf_...` instead of
|
||||
// browsing anonymously, so treat anything malformed as no token.
|
||||
export function hfApiToken(
|
||||
token: string | undefined | null,
|
||||
): string | undefined {
|
||||
return token && token.startsWith("hf_") ? token : undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,16 @@ export type SettingsTab =
|
|||
| "api-keys"
|
||||
| "about";
|
||||
|
||||
export type SettingsScrollTarget = "about-updates";
|
||||
|
||||
interface OpenDialogOptions {
|
||||
scrollTarget?: SettingsScrollTarget;
|
||||
}
|
||||
|
||||
interface SettingsDialogState {
|
||||
open: boolean;
|
||||
activeTab: SettingsTab;
|
||||
scrollTarget: SettingsScrollTarget | null;
|
||||
// Element focused when openDialog() ran. Radix's FocusScope normally tracks
|
||||
// this, but the rAF-scheduled focus() in settings-dialog.tsx races its
|
||||
// previous-focus capture, leaving focus on <body> after close. We restore
|
||||
|
|
@ -23,9 +30,10 @@ interface SettingsDialogState {
|
|||
// Set when something asks to jump straight to the archived chats list (the
|
||||
// archive toast). ChatTab consumes it to open the dialog, then clears it.
|
||||
archivedChatsRequested: boolean;
|
||||
openDialog: (tab?: SettingsTab) => void;
|
||||
openDialog: (tab?: SettingsTab, options?: OpenDialogOptions) => void;
|
||||
openArchivedChats: () => void;
|
||||
consumeArchivedChatsRequest: () => void;
|
||||
consumeScrollTarget: (target: SettingsScrollTarget) => void;
|
||||
closeDialog: () => void;
|
||||
setActiveTab: (tab: SettingsTab) => void;
|
||||
}
|
||||
|
|
@ -65,32 +73,39 @@ function loadInitialTab(): SettingsTab {
|
|||
export const useSettingsDialogStore = create<SettingsDialogState>((set) => ({
|
||||
open: false,
|
||||
activeTab: loadInitialTab(),
|
||||
scrollTarget: null,
|
||||
opener: null,
|
||||
archivedChatsRequested: false,
|
||||
openDialog: (tab) =>
|
||||
openDialog: (tab, options) =>
|
||||
set((state) => ({
|
||||
open: true,
|
||||
activeTab: tab ?? state.activeTab,
|
||||
scrollTarget: options?.scrollTarget ?? null,
|
||||
opener: captureOpener(),
|
||||
})),
|
||||
openArchivedChats: () =>
|
||||
set({
|
||||
open: true,
|
||||
activeTab: "chat",
|
||||
scrollTarget: null,
|
||||
archivedChatsRequested: true,
|
||||
opener: captureOpener(),
|
||||
}),
|
||||
consumeArchivedChatsRequest: () => set({ archivedChatsRequested: false }),
|
||||
consumeScrollTarget: (target) =>
|
||||
set((state) => ({
|
||||
scrollTarget: state.scrollTarget === target ? null : state.scrollTarget,
|
||||
})),
|
||||
// Do NOT clear `opener` here. onCloseAutoFocus runs on the next render
|
||||
// pass after `open: false` lands, so the opener must still be readable
|
||||
// from the store at that point. The next openDialog() overwrites it.
|
||||
closeDialog: () => set({ open: false }),
|
||||
closeDialog: () => set({ open: false, scrollTarget: null }),
|
||||
setActiveTab: (tab) => {
|
||||
try {
|
||||
window.localStorage.setItem(ACTIVE_TAB_KEY, tab);
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
set({ activeTab: tab });
|
||||
set({ activeTab: tab, scrollTarget: null });
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
NewReleasesIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { SettingsRow } from "../components/settings-row";
|
||||
import { SettingsSection } from "../components/settings-section";
|
||||
import { StudioVersionSection } from "../components/studio-version-section";
|
||||
|
|
@ -25,6 +25,7 @@ import {
|
|||
type UpdateInstallSource,
|
||||
UpdateStudioInstructions,
|
||||
} from "../components/update-studio-instructions";
|
||||
import { useSettingsDialogStore } from "../stores/settings-dialog-store";
|
||||
|
||||
type ApiObject = Record<string, unknown>;
|
||||
|
||||
|
|
@ -76,6 +77,11 @@ export function AboutTab() {
|
|||
const deviceType = usePlatformStore((s) => s.deviceType);
|
||||
const defaultShell = deviceType === "windows" ? "windows" : "unix";
|
||||
const hw = useHardwareInfo();
|
||||
const updateSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollTarget = useSettingsDialogStore((s) => s.scrollTarget);
|
||||
const consumeScrollTarget = useSettingsDialogStore(
|
||||
(s) => s.consumeScrollTarget,
|
||||
);
|
||||
const [shutdownOpen, setShutdownOpen] = useState(false);
|
||||
const [installSource, setInstallSource] = useState<
|
||||
UpdateInstallSource | "loading"
|
||||
|
|
@ -95,6 +101,20 @@ export function AboutTab() {
|
|||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollTarget !== "about-updates") {
|
||||
return;
|
||||
}
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
updateSectionRef.current?.scrollIntoView({
|
||||
block: "start",
|
||||
behavior: "smooth",
|
||||
});
|
||||
consumeScrollTarget("about-updates");
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [consumeScrollTarget, scrollTarget]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
|
|
@ -110,15 +130,17 @@ export function AboutTab() {
|
|||
Unsloth/Package rows; the prop keeps it About-only (General passes none). */}
|
||||
<StudioVersionSection llamaCppVersion={hw.llamaCpp} />
|
||||
|
||||
<SettingsSection title={t("settings.about.updates")}>
|
||||
<div className="py-2">
|
||||
<UpdateStudioInstructions
|
||||
defaultShell={defaultShell}
|
||||
installSource={isTauri ? null : installSource}
|
||||
showTitle={false}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<div ref={updateSectionRef} className="scroll-mt-5">
|
||||
<SettingsSection title={t("settings.about.updates")}>
|
||||
<div className="py-2">
|
||||
<UpdateStudioInstructions
|
||||
defaultShell={defaultShell}
|
||||
installSource={isTauri ? null : installSource}
|
||||
showTitle={false}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
||||
{hw.gpus.length > 0 || hw.cuda || hw.rocm ? (
|
||||
<SettingsSection title={t("settings.about.hardware")}>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,9 @@ import {
|
|||
useShowLlamaUpdateBanner,
|
||||
} from "@/hooks/use-llama-update-pref";
|
||||
import { LOCALE_STORAGE_KEY, useT } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { Check, Eye, EyeOff } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type HelperPrecacheSettings,
|
||||
|
|
@ -171,6 +172,12 @@ export function GeneralTab() {
|
|||
if (trimmed !== hfToken) setHfToken(trimmed);
|
||||
};
|
||||
|
||||
// Show an "accepted" tick once a non-empty token has been committed to the
|
||||
// store and the field still matches it (i.e. not mid-edit). Gives the user
|
||||
// feedback that a pasted token was saved.
|
||||
const tokenSaved =
|
||||
draftToken.trim().length > 0 && draftToken.trim() === (hfToken ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadUploadLimitSettings()
|
||||
|
|
@ -319,8 +326,22 @@ export function GeneralTab() {
|
|||
value={draftToken}
|
||||
onChange={(e) => setDraftToken(e.target.value)}
|
||||
onBlur={commitToken}
|
||||
className="h-8 w-full pr-8 font-mono text-xs"
|
||||
className={cn(
|
||||
"h-8 w-full font-mono text-xs",
|
||||
tokenSaved ? "pr-14" : "pr-8",
|
||||
)}
|
||||
/>
|
||||
{tokenSaved ? (
|
||||
// Decorative: pointer-events-none lets clicks reach the input
|
||||
// underneath so the field still focuses anywhere.
|
||||
<span
|
||||
className="pointer-events-none absolute right-7 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center text-emerald-600 duration-150 animate-in fade-in zoom-in dark:text-emerald-500"
|
||||
role="img"
|
||||
aria-label={t("settings.general.tokenSaved")}
|
||||
>
|
||||
<Check className="size-4" strokeWidth={2.5} />
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken((s) => !s)}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,10 @@ import {
|
|||
useTrainingRuntimeStore,
|
||||
} from "@/features/training";
|
||||
import { formatDuration } from "@/features/studio/sections/progress-section-lib";
|
||||
import { fetchDeviceType, usePlatformStore } from "@/config/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Delete02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
|
@ -194,6 +197,28 @@ export function HistoryCardGrid({
|
|||
const [manualFetchInFlight, setManualFetchInFlight] = useState(false);
|
||||
const { resumeTrainingRunFromHistory } = useTrainingActions();
|
||||
const isStarting = useTrainingRuntimeStore((state) => state.isStarting);
|
||||
// Copy-link base: Cloudflare tunnel > LAN host:port > origin. The tunnel
|
||||
// registers shortly after startup, so poll (bounded) until it shows.
|
||||
const cloudflareUrl = usePlatformStore((s) => s.cloudflareUrl);
|
||||
const serverUrl = usePlatformStore((s) => s.serverUrl);
|
||||
useEffect(() => {
|
||||
if (cloudflareUrl) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
for (let attempt = 0; attempt < 12 && !cancelled; attempt++) {
|
||||
try {
|
||||
await fetchDeviceType({ force: true });
|
||||
} catch {
|
||||
// Ignore startup blips; copy-link falls back to serverUrl/origin.
|
||||
}
|
||||
if (cancelled || usePlatformStore.getState().cloudflareUrl) return;
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cloudflareUrl]);
|
||||
|
||||
const userControllerRef = useRef<AbortController | null>(null);
|
||||
const pollControllerRef = useRef<AbortController | null>(null);
|
||||
|
|
@ -362,6 +387,8 @@ export function HistoryCardGrid({
|
|||
const isRunning = run.status === "running";
|
||||
const canResume = run.can_resume && !wasContinued;
|
||||
const isResuming = resumeTarget === run.id;
|
||||
// Backend /p ref, gated on previewability + route-expressible depth.
|
||||
const canCopyPreview = !!run.preview_ref;
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
|
|
@ -372,7 +399,7 @@ export function HistoryCardGrid({
|
|||
isRunning
|
||||
? "border-blue-400/50 dark:border-blue-500/30"
|
||||
: "border-border/60",
|
||||
canResume && "gap-2",
|
||||
(canResume || canCopyPreview) && "gap-2",
|
||||
)}
|
||||
onClick={() => onSelectRun(run.id)}
|
||||
onKeyDown={(e) => {
|
||||
|
|
@ -411,6 +438,38 @@ export function HistoryCardGrid({
|
|||
{isResuming ? t("studio.history.resuming") : t("studio.history.resumeTraining")}
|
||||
</Button>
|
||||
)}
|
||||
{canCopyPreview && (
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="absolute bottom-3 right-4 h-6 rounded-full px-2.5 text-[11px] leading-none shadow-sm"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
// Encode each segment but keep "/" so the /p route matches.
|
||||
const ref = (run.preview_ref ?? "")
|
||||
.split("/")
|
||||
.map(encodeURIComponent)
|
||||
.join("/");
|
||||
const base = (
|
||||
cloudflareUrl ??
|
||||
serverUrl ??
|
||||
window.location.origin
|
||||
).replace(/\/+$/, "");
|
||||
const url = `${base}/p/${ref}`;
|
||||
const ok = await copyToClipboard(url);
|
||||
toast[ok ? "success" : "error"](
|
||||
t(
|
||||
ok
|
||||
? "studio.history.previewLinkCopied"
|
||||
: "studio.history.previewLinkCopyFailed",
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("studio.history.copyPreviewLink")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="truncate text-sm font-medium"
|
||||
|
|
@ -434,7 +493,7 @@ export function HistoryCardGrid({
|
|||
</p>
|
||||
</div>
|
||||
{run.loss_sparkline && run.loss_sparkline.length >= 2 && (
|
||||
<div className={cn(canResume && "h-7 overflow-hidden")}>
|
||||
<div className={cn((canResume || canCopyPreview) && "h-7 overflow-hidden")}>
|
||||
<Sparkline
|
||||
values={run.loss_sparkline}
|
||||
id={run.id}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export interface TrainingRunSummary {
|
|||
output_dir: string | null;
|
||||
can_resume: boolean;
|
||||
resumed_later: boolean;
|
||||
has_preview_model: boolean;
|
||||
preview_ref: string | null;
|
||||
duration_seconds: number | null;
|
||||
error_message: string | null;
|
||||
loss_sparkline: number[] | null;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const en = {
|
|||
brand: "unsloth",
|
||||
product: "Unsloth Studio",
|
||||
accountMenu: "{name} account menu",
|
||||
updateAvailable: "Update available",
|
||||
aria: {
|
||||
home: "Unsloth home",
|
||||
closeSidebar: "Close sidebar",
|
||||
|
|
@ -106,6 +107,7 @@ export const en = {
|
|||
"Used to load gated models and push artifacts.",
|
||||
hideToken: "Hide token",
|
||||
showToken: "Show token",
|
||||
tokenSaved: "Token saved",
|
||||
password: "Password",
|
||||
passwordDescription: "Change the password for this Studio account.",
|
||||
passwordDialog: {
|
||||
|
|
@ -762,6 +764,9 @@ export const en = {
|
|||
running: "Training in progress",
|
||||
errored: "Training errored",
|
||||
},
|
||||
copyPreviewLink: "Copy preview link",
|
||||
previewLinkCopied: "Preview link copied",
|
||||
previewLinkCopyFailed: "Couldn't copy the link",
|
||||
},
|
||||
charts: {
|
||||
settings: "Chart Settings",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue