Merge branch 'main' into studio-model-idle-ttl
This commit is contained in:
commit
6590f18638
120 changed files with 4914 additions and 1246 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:
|
||||
|
|
|
|||
12
install.ps1
12
install.ps1
|
|
@ -2146,7 +2146,7 @@ exit 0
|
|||
if ($SkipTorch) {
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" }
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" }
|
||||
if ($baseInstallExit -eq 0) {
|
||||
# Resolve pydantic WITH deps so pip pins pydantic-core
|
||||
# to the matching version (no-torch-runtime.txt below
|
||||
|
|
@ -2160,7 +2160,7 @@ exit 0
|
|||
}
|
||||
}
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" }
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" }
|
||||
}
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
|
|
@ -2226,7 +2226,7 @@ exit 0
|
|||
if ($SkipTorch) {
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" }
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" }
|
||||
if ($baseInstallExit -eq 0) {
|
||||
# Same pydantic-with-deps trick as the migrated branch.
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install pydantic" { uv pip install --python $VenvPython pydantic }
|
||||
|
|
@ -2238,7 +2238,7 @@ exit 0
|
|||
}
|
||||
}
|
||||
} elseif ($StudioLocalInstall) {
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" }
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" }
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth" { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
|
||||
}
|
||||
|
|
@ -2266,7 +2266,7 @@ exit 0
|
|||
Write-TauriLog "STEP" "Installing unsloth"
|
||||
substep "installing unsloth (this may take a few minutes)..."
|
||||
if ($StudioLocalInstall) {
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.6.6" "unsloth>=2026.6.8" --torch-backend=auto }
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.6.7" "unsloth>=2026.6.9" --torch-backend=auto }
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
||||
|
|
@ -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 to allow HTTPS)"
|
||||
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 to allow HTTPS)"
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
install.sh
12
install.sh
|
|
@ -2621,7 +2621,7 @@ if [ "$_MIGRATED" = true ]; then
|
|||
# to prevent transitive torch resolution.
|
||||
run_install_cmd_retry "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
|
||||
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
||||
"unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6"
|
||||
"unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7"
|
||||
# Resolve pydantic WITH deps so pip pins pydantic-core to the
|
||||
# matching version (no-torch-runtime.txt below is --no-deps).
|
||||
# All transitive deps are torch-free.
|
||||
|
|
@ -2634,7 +2634,7 @@ if [ "$_MIGRATED" = true ]; then
|
|||
else
|
||||
run_install_cmd_retry "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
|
||||
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
||||
"unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6"
|
||||
"unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7"
|
||||
fi
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
substep "overlaying local repo (editable)..."
|
||||
|
|
@ -2838,7 +2838,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
run_install_cmd_retry "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
|
||||
--upgrade-package unsloth --upgrade-package unsloth-zoo \
|
||||
"unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6"
|
||||
"unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7"
|
||||
# Same pydantic-with-deps trick as the migrated branch.
|
||||
run_install_cmd_retry "install pydantic (with deps for compatible core)" \
|
||||
uv pip install --python "$_VENV_PY" pydantic
|
||||
|
|
@ -2856,7 +2856,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
fi
|
||||
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
run_install_cmd_retry "install unsloth (local)" uv pip install --python "$_VENV_PY" \
|
||||
--upgrade-package unsloth "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6"
|
||||
--upgrade-package unsloth "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7"
|
||||
substep "overlaying local repo (editable)..."
|
||||
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
||||
substep "overlaying unsloth-zoo from git main..."
|
||||
|
|
@ -2888,7 +2888,7 @@ else
|
|||
tauri_log "STEP" "Installing Unsloth"
|
||||
substep "installing unsloth (this may take a few minutes)..."
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.6.6" "unsloth>=2026.6.8" --torch-backend=auto
|
||||
run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.6.7" "unsloth>=2026.6.9" --torch-backend=auto
|
||||
substep "overlaying local repo (editable)..."
|
||||
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
||||
substep "overlaying unsloth-zoo from git main..."
|
||||
|
|
@ -3160,6 +3160,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 to allow HTTPS)"
|
||||
echo ""
|
||||
;;
|
||||
esac
|
||||
|
|
@ -3181,5 +3182,6 @@ else
|
|||
substep "unsloth studio -p 8888"
|
||||
fi
|
||||
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
substep "(add --secure to allow HTTPS)"
|
||||
echo ""
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ triton = [
|
|||
]
|
||||
|
||||
huggingfacenotorch = [
|
||||
"unsloth_zoo>=2026.6.6",
|
||||
"unsloth_zoo>=2026.6.7",
|
||||
"wheel>=0.42.0",
|
||||
"packaging",
|
||||
"numpy",
|
||||
|
|
@ -92,7 +92,7 @@ huggingfacenotorch = [
|
|||
]
|
||||
huggingface = [
|
||||
"unsloth[huggingfacenotorch]",
|
||||
"unsloth_zoo>=2026.6.6",
|
||||
"unsloth_zoo>=2026.6.7",
|
||||
"torchvision",
|
||||
"unsloth[triton]",
|
||||
]
|
||||
|
|
@ -582,7 +582,7 @@ colab-ampere-torch220 = [
|
|||
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
|
||||
]
|
||||
colab-new = [
|
||||
"unsloth_zoo>=2026.6.6",
|
||||
"unsloth_zoo>=2026.6.7",
|
||||
"packaging",
|
||||
"tyro",
|
||||
"transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "
|
||||
|
|
|
|||
|
|
@ -7861,6 +7861,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,
|
||||
|
|
@ -7998,6 +8007,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
|
||||
|
|
@ -8172,6 +8184,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:
|
||||
|
|
@ -8187,6 +8201,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
|
||||
|
||||
|
|
@ -8284,9 +8305,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 {
|
||||
|
|
@ -8695,6 +8717,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
|
||||
|
|
@ -8723,6 +8747,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 {
|
||||
|
|
@ -8755,6 +8785,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>"
|
||||
|
|
@ -8764,6 +8796,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)
|
||||
|
|
|
|||
|
|
@ -2545,14 +2545,24 @@ def _python_exec(
|
|||
pass
|
||||
try:
|
||||
fd, tmp_path = tempfile.mkstemp(suffix = ".py", prefix = "studio_exec_", dir = workdir)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
# utf-8 so non-ASCII in model-written code survives the OS default codec
|
||||
# (Windows cp1252 would otherwise raise UnicodeEncodeError).
|
||||
with os.fdopen(fd, "w", encoding = "utf-8") as f:
|
||||
f.write(code)
|
||||
|
||||
safe_env = _build_bypass_env(workdir) if disable_sandbox else _build_safe_env(workdir)
|
||||
if disable_sandbox:
|
||||
# Match the sandboxed Python path without changing bypass shell I/O.
|
||||
safe_env = dict(safe_env)
|
||||
safe_env["PYTHONIOENCODING"] = "utf-8"
|
||||
popen_kwargs = dict(
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
# Decode child output as utf-8 (it emits utf-8 via PYTHONIOENCODING);
|
||||
# replace so non-ASCII output never crashes the read on Windows.
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
cwd = workdir,
|
||||
env = safe_env,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -917,24 +917,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 = ["*"],
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 # 4.14+ breaks cancel scope on Py3.13 (#6483)
|
||||
anyio>=3.0,<4.14.0 # 4.14 asyncio cancel-scope RuntimeError on Py3.13 streaming (#6483); 4.13 unaffected
|
||||
sniffio
|
||||
h11
|
||||
|
||||
|
|
|
|||
|
|
@ -8,13 +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
|
||||
|
||||
# anyio 4.14+ breaks cancel scope on Python 3.13 (#6483). Global cap so later
|
||||
# with-deps steps (studio.txt, data-designer-deps.txt) 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
|
||||
|
|
|
|||
|
|
@ -3,3 +3,10 @@
|
|||
# backtrack unsloth. Relax to match the pin -- per-model 5.x routing
|
||||
# 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 (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
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import uuid
|
|||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import StreamingResponse, JSONResponse, Response
|
||||
from starlette.requests import ClientDisconnect
|
||||
from typing import Any, List, Optional, Union
|
||||
import json
|
||||
import httpx
|
||||
|
|
@ -235,8 +236,15 @@ def _sse_streaming_response(content) -> StreamingResponse:
|
|||
a one-shot connection. Two callers build their response inline instead: the
|
||||
external-provider proxy omits ``Connection: close``, and the OpenAI
|
||||
passthrough returns an empty ``keep-alive`` stream when the request is
|
||||
cancelled before the upstream response starts."""
|
||||
return StreamingResponse(
|
||||
cancelled before the upstream response starts.
|
||||
|
||||
Built on ``_SameTaskStreamingResponse`` (not Starlette's stock
|
||||
``StreamingResponse``) so the SSE generator runs in the request task. The
|
||||
legacy AnyIO task-group wrapper trips "Attempted to exit a cancel scope in a
|
||||
different task" on Python 3.13 + httpx, which surfaced as a mid-stream
|
||||
``response.failed``. The streaming paths that take their response inline use
|
||||
``_SameTaskStreamingResponse`` directly for the same reason."""
|
||||
return _SameTaskStreamingResponse(
|
||||
content,
|
||||
media_type = "text/event-stream",
|
||||
headers = {
|
||||
|
|
@ -750,6 +758,121 @@ def _set_stream_response_read_timeout(
|
|||
pass
|
||||
|
||||
|
||||
_STREAM_DISCONNECT_POLL_TIMEOUT_S = 0.25
|
||||
|
||||
|
||||
class _CompatSameTaskTimeout:
|
||||
"""Same-task timeout fallback for Python versions before asyncio.timeout."""
|
||||
|
||||
def __init__(self, timeout_s: float):
|
||||
self.timeout_s = timeout_s
|
||||
self._task = None
|
||||
self._handle = None
|
||||
self._timed_out = False
|
||||
self._cancelling = 0
|
||||
|
||||
async def __aenter__(self):
|
||||
self._task = asyncio.current_task()
|
||||
if self._task is None:
|
||||
return self
|
||||
if hasattr(self._task, "cancelling"):
|
||||
self._cancelling = self._task.cancelling()
|
||||
loop = asyncio.get_running_loop()
|
||||
self._handle = loop.call_later(max(self.timeout_s, 0), self._cancel_task)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
if self._handle is not None:
|
||||
self._handle.cancel()
|
||||
if exc_type is not None and issubclass(exc_type, asyncio.CancelledError):
|
||||
if self._timed_out:
|
||||
if self._task is not None and hasattr(self._task, "uncancel"):
|
||||
if self._task.uncancel() > self._cancelling:
|
||||
return None
|
||||
raise asyncio.TimeoutError from exc
|
||||
return None
|
||||
|
||||
def _cancel_task(self) -> None:
|
||||
self._timed_out = True
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
|
||||
|
||||
def _same_task_timeout(timeout_s: float):
|
||||
timeout_ctx = getattr(asyncio, "timeout", None)
|
||||
if timeout_ctx is not None:
|
||||
return timeout_ctx(timeout_s)
|
||||
return _CompatSameTaskTimeout(timeout_s)
|
||||
|
||||
|
||||
class _SameTaskStreamingResponse(StreamingResponse):
|
||||
"""StreamingResponse without Starlette's legacy AnyIO task-group wrapper."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
unstarted_cleanup = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
# Async callable invoked when the client disconnects before the body
|
||||
# iterator is ever advanced. A generator that never started cannot run
|
||||
# its own try/finally, so a stream that acquires resources before its
|
||||
# first yield (the passthrough opens an upstream httpx stream eagerly)
|
||||
# passes this to release them.
|
||||
self._unstarted_cleanup = unstarted_cleanup
|
||||
|
||||
async def __call__(self, scope, receive, send) -> None:
|
||||
# Track whether the body iterator was ever advanced: send() only emits a
|
||||
# body message after the generator yields its first chunk, so a failure
|
||||
# before then means it never entered its try/finally.
|
||||
body_started = False
|
||||
|
||||
async def _tracking_send(message) -> None:
|
||||
nonlocal body_started
|
||||
if message.get("type") == "http.response.body":
|
||||
body_started = True
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self.stream_response(_tracking_send)
|
||||
except OSError:
|
||||
# Client disconnected mid-send.
|
||||
if body_started:
|
||||
# The generator produced at least one chunk and is suspended in
|
||||
# its try/finally. Throw CancelledError into it (not aclose's
|
||||
# GeneratorExit) so its `except asyncio.CancelledError` handler
|
||||
# runs and finishes any api_monitor entry; GeneratorExit would
|
||||
# skip it and only run `finally`. Fall back to aclose() without
|
||||
# athrow.
|
||||
athrow = getattr(self.body_iterator, "athrow", None)
|
||||
if athrow is not None:
|
||||
try:
|
||||
await athrow(asyncio.CancelledError())
|
||||
except (asyncio.CancelledError, StopAsyncIteration, RuntimeError):
|
||||
pass
|
||||
else:
|
||||
aclose = getattr(self.body_iterator, "aclose", None)
|
||||
if aclose is not None:
|
||||
await aclose()
|
||||
else:
|
||||
# http.response.start failed before the body iterator advanced,
|
||||
# so its try/finally never armed and aclose()/athrow() are no-ops
|
||||
# on an unstarted generator. Release any resources acquired
|
||||
# before the first yield via the explicit cleanup hook.
|
||||
aclose = getattr(self.body_iterator, "aclose", None)
|
||||
if aclose is not None:
|
||||
await aclose()
|
||||
if self._unstarted_cleanup is not None:
|
||||
try:
|
||||
await self._unstarted_cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
raise ClientDisconnect()
|
||||
if self.background is not None:
|
||||
await self.background()
|
||||
|
||||
|
||||
async def _aclose_stream_resources(
|
||||
*,
|
||||
watchers = (),
|
||||
|
|
@ -875,8 +998,23 @@ async def _aiter_llama_stream_items(
|
|||
raise httpx.ReadTimeout("The model did not produce a first token in time.")
|
||||
if response is not None:
|
||||
_set_stream_response_read_timeout(response, remaining_s)
|
||||
item = await asyncio.wait_for(async_iter.__anext__(), timeout = remaining_s)
|
||||
# Keep httpx/httpcore's AnyIO cancel scope in this task.
|
||||
# asyncio.wait_for would drive __anext__ in a child task.
|
||||
async with _same_task_timeout(remaining_s):
|
||||
item = await async_iter.__anext__()
|
||||
else:
|
||||
if (
|
||||
request is not None
|
||||
and response is not None
|
||||
and post_first_item_read_timeout_s is not None
|
||||
and last_item_at is not None
|
||||
):
|
||||
stall_remaining_s = post_first_item_read_timeout_s - (
|
||||
time.monotonic() - last_item_at
|
||||
)
|
||||
if stall_remaining_s <= 0:
|
||||
raise httpx.ReadTimeout("The model stopped producing tokens mid-response.")
|
||||
_set_stream_response_read_timeout(response, stall_remaining_s)
|
||||
item = await async_iter.__anext__()
|
||||
except asyncio.TimeoutError as exc:
|
||||
if waiting_first_item:
|
||||
|
|
@ -890,6 +1028,12 @@ async def _aiter_llama_stream_items(
|
|||
if now >= first_token_deadline:
|
||||
raise
|
||||
continue
|
||||
if (
|
||||
request is not None
|
||||
and post_first_item_read_timeout_s is not None
|
||||
and now - last_item_at < post_first_item_read_timeout_s
|
||||
):
|
||||
continue
|
||||
raise httpx.ReadTimeout("The model stopped producing tokens mid-response.")
|
||||
if (
|
||||
last_item_at is None
|
||||
|
|
@ -1125,16 +1269,17 @@ def _detect_safetensors_features(backend, chat_template: Optional[str]) -> dict:
|
|||
model_identifier = model_id,
|
||||
log_source = "safetensors",
|
||||
)
|
||||
# Our safetensors loop only parses <tool_call>{json}</tool_call> and
|
||||
# <function=name>...</function>. Llama uses <|python_tag|>, Mistral uses
|
||||
# [TOOL_CALLS]; advertising tools for those enables a pill the parser
|
||||
# can't honour. GGUF is unaffected -- llama-server normalises every
|
||||
# format into structured deltas.
|
||||
# Our safetensors loop only parses <tool_call>{json}</tool_call>,
|
||||
# <function=name>...</function>, and Gemma native <|tool_call>...<tool_call|>.
|
||||
# Llama uses <|python_tag|>, Mistral uses [TOOL_CALLS]; advertising tools for
|
||||
# those enables a pill the parser can't honour. GGUF is unaffected --
|
||||
# llama-server normalises every format into structured deltas.
|
||||
if (
|
||||
flags.get("supports_tools")
|
||||
and chat_template
|
||||
and "<tool_call>" not in chat_template
|
||||
and "<function=" not in chat_template
|
||||
and "<|tool_call>" not in chat_template
|
||||
):
|
||||
logger.info(
|
||||
"safetensors: template advertises tools but uses an "
|
||||
|
|
@ -1297,6 +1442,24 @@ async def _await_disconnect_then_close(request, resp, cancel_event) -> None:
|
|||
return
|
||||
|
||||
|
||||
async def _await_disconnect_then_cancel(request, cancel_event) -> None:
|
||||
"""Set ``cancel_event`` when a same-task local stream disconnects."""
|
||||
try:
|
||||
while not await request.is_disconnected():
|
||||
await asyncio.sleep(0.1)
|
||||
cancel_event.set()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
|
||||
async def _stop_local_disconnect_cancel_watcher(watcher) -> None:
|
||||
watcher.cancel()
|
||||
try:
|
||||
await watcher
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
|
||||
# Centralized local/server tool nudge. Keep render_html guidance gated to turns
|
||||
# where the canvas tool is actually present in the tool schema; otherwise
|
||||
# small local models can hallucinate a missing tool call instead of following
|
||||
|
|
@ -1418,7 +1581,9 @@ _TOOL_XML_RE = _re.compile(
|
|||
# Hyphen in the name char-class matches MCP tool names with dashes
|
||||
# (mcp__srv__list-issues) that would otherwise leak past this strip.
|
||||
r"<(?:tool_call|function=[\w-]+)>.*?(?:</(?:tool_call|function)>|\Z)"
|
||||
r"|<\|tool_call>.*?(?:<tool_call\|>|\Z)"
|
||||
r"|</(?:tool_call|function)>"
|
||||
r"|<tool_call\|>"
|
||||
r"|</parameter>\s*\Z",
|
||||
_re.DOTALL,
|
||||
)
|
||||
|
|
@ -3234,7 +3399,9 @@ async def get_api_monitor_entry(entry_id: str, current_subject: str = Depends(ge
|
|||
|
||||
@router.post("/generate/stream")
|
||||
async def generate_stream(
|
||||
request: GenerateRequest, current_subject: str = Depends(get_current_subject)
|
||||
request: GenerateRequest,
|
||||
fastapi_request: Request,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""
|
||||
Generate a chat response with Server-Sent Events (SSE) streaming.
|
||||
|
|
@ -3284,6 +3451,13 @@ async def generate_stream(
|
|||
async def stream():
|
||||
gen = None
|
||||
completed = False
|
||||
# Cancel the generation when the client disconnects. The generator only
|
||||
# awaits asyncio.to_thread(next, gen, ...), so without a concurrent
|
||||
# watcher a disconnect during a long prefill/generation would go
|
||||
# unnoticed until the next send and the backend would keep generating.
|
||||
disconnect_watcher = asyncio.create_task(
|
||||
_await_disconnect_then_cancel(fastapi_request, cancel_event)
|
||||
)
|
||||
try:
|
||||
gen = backend.generate_chat_response(
|
||||
messages = request.messages,
|
||||
|
|
@ -3298,12 +3472,22 @@ async def generate_stream(
|
|||
)
|
||||
_DONE = object()
|
||||
while True:
|
||||
if cancel_event.is_set():
|
||||
# The disconnect watcher set cancel_event between chunks.
|
||||
# Reset the backend here: closing the Python generator does
|
||||
# not signal a subprocess backend, so without this it keeps
|
||||
# decoding after the client is gone. The finally's reset is
|
||||
# guarded on cancel_event being unset, so it will not run
|
||||
# again for this path.
|
||||
backend.reset_generation_state()
|
||||
break
|
||||
chunk = await asyncio.to_thread(next, gen, _DONE)
|
||||
if chunk is _DONE:
|
||||
completed = True
|
||||
break
|
||||
yield f"data: {json.dumps({'content': chunk})}\n\n"
|
||||
completed = True
|
||||
yield "data: [DONE]\n\n"
|
||||
if completed:
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
except asyncio.CancelledError:
|
||||
cancel_event.set()
|
||||
|
|
@ -3315,6 +3499,7 @@ async def generate_stream(
|
|||
logger.error(f"Error during generation: {e}", exc_info = True)
|
||||
yield f"data: {json.dumps({'error': _friendly_error(e)})}\n\n"
|
||||
finally:
|
||||
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
|
||||
if not completed and not cancel_event.is_set():
|
||||
cancel_event.set()
|
||||
backend.reset_generation_state()
|
||||
|
|
@ -4742,6 +4927,9 @@ async def openai_chat_completions(
|
|||
_tracker.__enter__()
|
||||
|
||||
async def audio_input_stream():
|
||||
disconnect_watcher = asyncio.create_task(
|
||||
_await_disconnect_then_cancel(request, cancel_event)
|
||||
)
|
||||
try:
|
||||
yield _chat_role_chunk(completion_id, created, model_name)
|
||||
|
||||
|
|
@ -4777,9 +4965,18 @@ async def openai_chat_completions(
|
|||
api_monitor.fail(monitor_id, _friendly_error(e))
|
||||
yield f"data: {json.dumps({'error': {'message': _friendly_error(e), 'type': 'server_error'}})}\n\n"
|
||||
finally:
|
||||
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
|
||||
_tracker.__exit__(None, None, None)
|
||||
|
||||
return _sse_streaming_response(audio_input_stream())
|
||||
return _SameTaskStreamingResponse(
|
||||
audio_input_stream(),
|
||||
media_type = "text/event-stream",
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "close",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
else:
|
||||
try:
|
||||
full_text = "".join(audio_input_generate())
|
||||
|
|
@ -4954,6 +5151,28 @@ async def openai_chat_completions(
|
|||
completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
|
||||
created = int(time.time())
|
||||
|
||||
def _new_chat_reasoning_extractor():
|
||||
return _ResponsesReasoningExtractor(
|
||||
parse_think_markers = _responses_should_parse_think_markers(
|
||||
payload,
|
||||
llama_backend,
|
||||
)
|
||||
)
|
||||
|
||||
def _gguf_chat_delta_line(delta: ChoiceDelta, finish_reason = None) -> str:
|
||||
chunk = ChatCompletionChunk(
|
||||
id = completion_id,
|
||||
created = created,
|
||||
model = model_name,
|
||||
choices = [
|
||||
ChunkChoice(
|
||||
delta = delta,
|
||||
finish_reason = finish_reason,
|
||||
)
|
||||
],
|
||||
)
|
||||
return f"data: {chunk.model_dump_json(exclude_none = True)}\n\n"
|
||||
|
||||
# ── Tool-calling path (agentic loop) ──────────────────
|
||||
# `_effective_enable_tools` lets `unsloth run --enable-tools/--disable-tools`
|
||||
# hard-override the per-request value, else falls back to
|
||||
|
|
@ -5066,6 +5285,9 @@ async def openai_chat_completions(
|
|||
|
||||
async def gguf_tool_stream():
|
||||
gen = None
|
||||
disconnect_watcher = asyncio.create_task(
|
||||
_await_disconnect_then_cancel(request, cancel_event)
|
||||
)
|
||||
try:
|
||||
yield _chat_role_chunk(completion_id, created, model_name)
|
||||
|
||||
|
|
@ -5073,9 +5295,25 @@ async def openai_chat_completions(
|
|||
# stays free for disconnect detection.
|
||||
gen = gguf_generate_with_tools()
|
||||
prev_text = ""
|
||||
reasoning_extractor = _new_chat_reasoning_extractor()
|
||||
_stream_usage = None
|
||||
_stream_timings = None
|
||||
_stream_finish = None
|
||||
|
||||
def _flush_reasoning_extractor():
|
||||
final_reasoning, final_visible = reasoning_extractor.finish()
|
||||
chunks = []
|
||||
if final_reasoning:
|
||||
chunks.append(
|
||||
_gguf_chat_delta_line(
|
||||
ChoiceDelta(reasoning_content = final_reasoning)
|
||||
)
|
||||
)
|
||||
if final_visible:
|
||||
api_monitor.append_reply(monitor_id, final_visible)
|
||||
chunks.append(_gguf_chat_delta_line(ChoiceDelta(content = final_visible)))
|
||||
return chunks
|
||||
|
||||
while True:
|
||||
if cancel_event.is_set():
|
||||
break
|
||||
|
|
@ -5094,7 +5332,10 @@ async def openai_chat_completions(
|
|||
# cumulative cursor so the next assistant turn
|
||||
# streams cleanly.
|
||||
if not event["text"]:
|
||||
for chunk in _flush_reasoning_extractor():
|
||||
yield chunk
|
||||
prev_text = ""
|
||||
reasoning_extractor = _new_chat_reasoning_extractor()
|
||||
# Emit tool status as a custom SSE event (including
|
||||
# empty ones to clear UI badges)
|
||||
status_data = json.dumps(
|
||||
|
|
@ -5108,7 +5349,10 @@ async def openai_chat_completions(
|
|||
|
||||
if event["type"] in ("tool_start", "tool_end"):
|
||||
if event["type"] == "tool_start":
|
||||
for chunk in _flush_reasoning_extractor():
|
||||
yield chunk
|
||||
prev_text = ""
|
||||
reasoning_extractor = _new_chat_reasoning_extractor()
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
continue
|
||||
|
||||
|
|
@ -5118,6 +5362,11 @@ async def openai_chat_completions(
|
|||
_stream_finish = event.get("finish_reason")
|
||||
continue
|
||||
|
||||
if event["type"] == "reasoning_summary":
|
||||
# Forward server-side reasoning timing to the UI.
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
continue
|
||||
|
||||
# "content" type -- cumulative text. Sanitize the full
|
||||
# cumulative then diff against the last sanitized
|
||||
# snapshot so cross-chunk XML tags are handled correctly.
|
||||
|
|
@ -5130,15 +5379,33 @@ async def openai_chat_completions(
|
|||
prev_text = clean_cumulative
|
||||
if not new_text:
|
||||
continue
|
||||
api_monitor.append_reply(monitor_id, new_text)
|
||||
yield _chat_content_chunk(completion_id, created, model_name, new_text)
|
||||
reasoning_delta, visible_delta = reasoning_extractor.feed(new_text)
|
||||
if reasoning_delta:
|
||||
yield _gguf_chat_delta_line(
|
||||
ChoiceDelta(reasoning_content = reasoning_delta)
|
||||
)
|
||||
if visible_delta:
|
||||
api_monitor.append_reply(monitor_id, visible_delta)
|
||||
yield _gguf_chat_delta_line(ChoiceDelta(content = visible_delta))
|
||||
|
||||
yield _chat_final_chunk(
|
||||
completion_id,
|
||||
created,
|
||||
model_name,
|
||||
_clamp_finish_reason(_stream_finish),
|
||||
for chunk in _flush_reasoning_extractor():
|
||||
yield chunk
|
||||
|
||||
final_chunk = ChatCompletionChunk(
|
||||
id = completion_id,
|
||||
created = created,
|
||||
model = model_name,
|
||||
choices = [
|
||||
ChunkChoice(
|
||||
delta = ChoiceDelta(),
|
||||
finish_reason = _clamp_finish_reason(_stream_finish),
|
||||
)
|
||||
],
|
||||
)
|
||||
# Emit the terminal chunk carrying finish_reason before the
|
||||
# optional usage chunk and [DONE], so OpenAI-compatible
|
||||
# clients can detect stop/length/tool_calls.
|
||||
yield f"data: {final_chunk.model_dump_json(exclude_none = True)}\n\n"
|
||||
usage_line = _openai_stream_usage_chunk(
|
||||
payload,
|
||||
completion_id,
|
||||
|
|
@ -5167,6 +5434,7 @@ async def openai_chat_completions(
|
|||
error_chunk = _openai_stream_error_chunk(e)
|
||||
yield f"data: {json.dumps(error_chunk)}\n\n"
|
||||
finally:
|
||||
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
|
||||
if gen is not None:
|
||||
try:
|
||||
gen.close()
|
||||
|
|
@ -5174,7 +5442,15 @@ async def openai_chat_completions(
|
|||
pass
|
||||
_tracker.__exit__(None, None, None)
|
||||
|
||||
return _sse_streaming_response(gguf_tool_stream())
|
||||
return _SameTaskStreamingResponse(
|
||||
gguf_tool_stream(),
|
||||
media_type = "text/event-stream",
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "close",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
# ── Standard GGUF path (no tools) ─────────────────────
|
||||
|
||||
|
|
@ -5210,6 +5486,9 @@ async def openai_chat_completions(
|
|||
_tracker.__enter__()
|
||||
|
||||
async def gguf_stream_chunks():
|
||||
disconnect_watcher = asyncio.create_task(
|
||||
_await_disconnect_then_cancel(request, cancel_event)
|
||||
)
|
||||
try:
|
||||
yield _chat_role_chunk(completion_id, created, model_name)
|
||||
|
||||
|
|
@ -5217,6 +5496,7 @@ async def openai_chat_completions(
|
|||
# stays free for disconnect detection.
|
||||
gen = gguf_generate()
|
||||
prev_text = ""
|
||||
reasoning_extractor = _new_chat_reasoning_extractor()
|
||||
_stream_usage = None
|
||||
_stream_timings = None
|
||||
_stream_finish = None
|
||||
|
|
@ -5250,15 +5530,38 @@ async def openai_chat_completions(
|
|||
prev_text = cumulative
|
||||
if not new_text:
|
||||
continue
|
||||
api_monitor.append_reply(monitor_id, new_text)
|
||||
yield _chat_content_chunk(completion_id, created, model_name, new_text)
|
||||
reasoning_delta, visible_delta = reasoning_extractor.feed(new_text)
|
||||
if reasoning_delta:
|
||||
yield _gguf_chat_delta_line(
|
||||
ChoiceDelta(reasoning_content = reasoning_delta)
|
||||
)
|
||||
if visible_delta:
|
||||
api_monitor.append_reply(monitor_id, visible_delta)
|
||||
yield _gguf_chat_delta_line(ChoiceDelta(content = visible_delta))
|
||||
|
||||
yield _chat_final_chunk(
|
||||
completion_id,
|
||||
created,
|
||||
model_name,
|
||||
_clamp_finish_reason(_stream_finish),
|
||||
final_reasoning, final_visible = reasoning_extractor.finish()
|
||||
if final_reasoning:
|
||||
yield _gguf_chat_delta_line(ChoiceDelta(reasoning_content = final_reasoning))
|
||||
if final_visible:
|
||||
api_monitor.append_reply(monitor_id, final_visible)
|
||||
yield _gguf_chat_delta_line(ChoiceDelta(content = final_visible))
|
||||
|
||||
# Final chunk
|
||||
final_chunk = ChatCompletionChunk(
|
||||
id = completion_id,
|
||||
created = created,
|
||||
model = model_name,
|
||||
choices = [
|
||||
ChunkChoice(
|
||||
delta = ChoiceDelta(),
|
||||
finish_reason = _clamp_finish_reason(_stream_finish),
|
||||
)
|
||||
],
|
||||
)
|
||||
# Emit the terminal chunk carrying finish_reason before the
|
||||
# optional usage chunk and [DONE], so OpenAI-compatible
|
||||
# clients can detect stop/length/tool_calls.
|
||||
yield f"data: {final_chunk.model_dump_json(exclude_none = True)}\n\n"
|
||||
usage_line = _openai_stream_usage_chunk(
|
||||
payload,
|
||||
completion_id,
|
||||
|
|
@ -5285,9 +5588,18 @@ async def openai_chat_completions(
|
|||
error_chunk = _openai_stream_error_chunk(e)
|
||||
yield f"data: {json.dumps(error_chunk)}\n\n"
|
||||
finally:
|
||||
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
|
||||
_tracker.__exit__(None, None, None)
|
||||
|
||||
return _sse_streaming_response(gguf_stream_chunks())
|
||||
return _SameTaskStreamingResponse(
|
||||
gguf_stream_chunks(),
|
||||
media_type = "text/event-stream",
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "close",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
else:
|
||||
try:
|
||||
# ``n`` requests several independent completions; the single
|
||||
|
|
@ -5314,14 +5626,24 @@ async def openai_chat_completions(
|
|||
continue
|
||||
full_text = token
|
||||
|
||||
reasoning_text, visible_text = _extract_responses_reasoning(
|
||||
full_text,
|
||||
parse_think_markers = _responses_should_parse_think_markers(
|
||||
payload,
|
||||
llama_backend,
|
||||
),
|
||||
)
|
||||
message_kwargs = {"content": visible_text}
|
||||
if reasoning_text:
|
||||
message_kwargs["reasoning_content"] = reasoning_text
|
||||
_choices.append(
|
||||
CompletionChoice(
|
||||
index = _idx,
|
||||
message = CompletionMessage(content = full_text),
|
||||
message = CompletionMessage(**message_kwargs),
|
||||
finish_reason = _clamp_finish_reason(completion_finish),
|
||||
)
|
||||
)
|
||||
_monitor_replies.append(full_text)
|
||||
_monitor_replies.append(visible_text)
|
||||
if completion_usage:
|
||||
# The prompt is shared across all n choices, so count its
|
||||
# tokens ONCE (OpenAI bills only generated tokens for each
|
||||
|
|
@ -5343,7 +5665,7 @@ async def openai_chat_completions(
|
|||
prompt_tokens_details = _prompt_tokens_details(_prompt_details),
|
||||
),
|
||||
)
|
||||
monitor_reply = full_text
|
||||
monitor_reply = _monitor_replies[-1] if _monitor_replies else ""
|
||||
if _n > 1:
|
||||
monitor_reply = "\n\n".join(
|
||||
f"Choice {_idx + 1}:\n{text}" for _idx, text in enumerate(_monitor_replies)
|
||||
|
|
@ -5553,6 +5875,9 @@ async def openai_chat_completions(
|
|||
|
||||
async def sf_tool_stream():
|
||||
gen = None
|
||||
disconnect_watcher = asyncio.create_task(
|
||||
_await_disconnect_then_cancel(request, cancel_event)
|
||||
)
|
||||
try:
|
||||
yield _chat_role_chunk(completion_id, created, model_name)
|
||||
|
||||
|
|
@ -5644,6 +5969,7 @@ async def openai_chat_completions(
|
|||
}
|
||||
yield f"data: {json.dumps(error_chunk)}\n\n"
|
||||
finally:
|
||||
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
|
||||
if gen is not None:
|
||||
try:
|
||||
gen.close()
|
||||
|
|
@ -5652,7 +5978,15 @@ async def openai_chat_completions(
|
|||
_sf_tracker.__exit__(None, None, None)
|
||||
|
||||
if payload.stream:
|
||||
return _sse_streaming_response(sf_tool_stream())
|
||||
return _SameTaskStreamingResponse(
|
||||
sf_tool_stream(),
|
||||
media_type = "text/event-stream",
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "close",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
# Non-streaming JSON: drain the loop, build one ChatCompletion.
|
||||
try:
|
||||
|
|
@ -5754,6 +6088,9 @@ async def openai_chat_completions(
|
|||
_tracker.__enter__()
|
||||
|
||||
async def stream_chunks():
|
||||
disconnect_watcher = asyncio.create_task(
|
||||
_await_disconnect_then_cancel(request, cancel_event)
|
||||
)
|
||||
try:
|
||||
yield _chat_role_chunk(completion_id, created, model_name)
|
||||
|
||||
|
|
@ -5830,9 +6167,18 @@ async def openai_chat_completions(
|
|||
}
|
||||
yield f"data: {json.dumps(error_chunk)}\n\n"
|
||||
finally:
|
||||
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
|
||||
_tracker.__exit__(None, None, None)
|
||||
|
||||
return _sse_streaming_response(stream_chunks())
|
||||
return _SameTaskStreamingResponse(
|
||||
stream_chunks(),
|
||||
media_type = "text/event-stream",
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "close",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
# ── Non-streaming response ────────────────────────────────────
|
||||
else:
|
||||
|
|
@ -6552,8 +6898,9 @@ def _responses_should_parse_think_markers(
|
|||
if llama_backend is not None and getattr(llama_backend, "is_loaded", False):
|
||||
if getattr(llama_backend, "reasoning_always_on", False):
|
||||
return True
|
||||
if not getattr(llama_backend, "supports_reasoning", False):
|
||||
return False
|
||||
if getattr(llama_backend, "supports_reasoning", False):
|
||||
return True
|
||||
return False
|
||||
if chat_req.enable_thinking is True:
|
||||
return True
|
||||
return chat_req.enable_thinking is None and chat_req.reasoning_effort not in (None, "none")
|
||||
|
|
@ -6849,8 +7196,6 @@ async def _responses_non_streaming(
|
|||
# the model produced content, so clients expecting a pure tool-call turn
|
||||
# (finish_reason="tool_calls") don't see a spurious empty message item.
|
||||
output_items: list[dict] = []
|
||||
if reasoning_text and not text and not tool_calls:
|
||||
text = reasoning_text
|
||||
if reasoning_text:
|
||||
output_items.append(_responses_reasoning_output_item(reasoning_text))
|
||||
if text:
|
||||
|
|
@ -7163,8 +7508,8 @@ async def _responses_stream(
|
|||
client = httpx.AsyncClient(timeout = _llama_streaming_generation_timeout())
|
||||
resp = None
|
||||
lines_iter = None
|
||||
disconnect_event = threading.Event()
|
||||
disconnect_watcher = None
|
||||
disconnect_event = threading.Event()
|
||||
try:
|
||||
req = client.build_request(
|
||||
"POST", target_url, json = body, headers = {"Connection": "close"}
|
||||
|
|
@ -7224,10 +7569,10 @@ async def _responses_stream(
|
|||
)
|
||||
return
|
||||
|
||||
lines_iter = resp.aiter_lines()
|
||||
disconnect_watcher = asyncio.create_task(
|
||||
_await_disconnect_then_close(request, resp, disconnect_event)
|
||||
)
|
||||
lines_iter = resp.aiter_lines()
|
||||
async for raw_line in _aiter_llama_stream_items(
|
||||
lines_iter,
|
||||
cancel_event = disconnect_event,
|
||||
|
|
@ -7347,6 +7692,7 @@ async def _responses_stream(
|
|||
|
||||
_apply_usage(chunk_data.get("usage"))
|
||||
except asyncio.CancelledError:
|
||||
disconnect_event.set()
|
||||
api_monitor.finish(monitor_id, "cancelled")
|
||||
raise
|
||||
except (httpx.RemoteProtocolError, httpx.ReadError, httpx.CloseError) as e:
|
||||
|
|
@ -7413,21 +7759,6 @@ async def _responses_stream(
|
|||
"delta": final_visible,
|
||||
},
|
||||
)
|
||||
if full_reasoning and not full_text and not tool_call_state:
|
||||
for event in _ensure_message_open():
|
||||
yield event
|
||||
full_text = full_reasoning
|
||||
api_monitor.set_reply(monitor_id, full_text)
|
||||
yield _sse(
|
||||
"response.output_text.delta",
|
||||
{
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": message_state["item_id"],
|
||||
"output_index": message_state["output_index"],
|
||||
"content_index": 0,
|
||||
"delta": full_text,
|
||||
},
|
||||
)
|
||||
|
||||
close_items: list[tuple[int, str, dict[str, Any]]] = []
|
||||
if reasoning_state["opened"]:
|
||||
|
|
@ -7588,7 +7919,15 @@ async def _responses_stream(
|
|||
api_monitor.finish(monitor_id)
|
||||
yield _sse("response.completed", completed_response)
|
||||
|
||||
return _sse_streaming_response(event_generator())
|
||||
return _SameTaskStreamingResponse(
|
||||
event_generator(),
|
||||
media_type = "text/event-stream",
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "close",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/responses")
|
||||
|
|
@ -8204,9 +8543,17 @@ async def _anthropic_tool_stream(
|
|||
drop_until_tool_end = False
|
||||
|
||||
gen = run_gen()
|
||||
# Concurrent disconnect watcher: the loop only polls is_disconnected()
|
||||
# between events, so a client disconnect during a long prefill or
|
||||
# generation step would otherwise hold the decode slot until the next
|
||||
# event or a failed send. The watcher sets cancel_event so the backend
|
||||
# stops promptly.
|
||||
disconnect_watcher = asyncio.create_task(
|
||||
_await_disconnect_then_cancel(request, cancel_event)
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
if cancel_event.is_set() or await request.is_disconnected():
|
||||
cancel_event.set()
|
||||
return
|
||||
event = await asyncio.to_thread(next, gen, _sentinel)
|
||||
|
|
@ -8254,6 +8601,8 @@ async def _anthropic_tool_stream(
|
|||
if _error_event is not None:
|
||||
yield _error_event
|
||||
return
|
||||
finally:
|
||||
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
|
||||
|
||||
stop_reason = openai_finish_to_anthropic_stop(
|
||||
captured_finish_reason, had_tool_calls = ends_on_tool_use
|
||||
|
|
@ -8290,9 +8639,17 @@ async def _anthropic_plain_stream(
|
|||
captured_finish_reason = None
|
||||
|
||||
gen = run_gen()
|
||||
# Concurrent disconnect watcher: the loop only polls is_disconnected()
|
||||
# between chunks, so a client disconnect during a long prefill or
|
||||
# generation step would otherwise hold the decode slot until the next
|
||||
# chunk or a failed send. The watcher sets cancel_event so the backend
|
||||
# stops promptly.
|
||||
disconnect_watcher = asyncio.create_task(
|
||||
_await_disconnect_then_cancel(request, cancel_event)
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
if cancel_event.is_set() or await request.is_disconnected():
|
||||
cancel_event.set()
|
||||
return
|
||||
cumulative = await asyncio.to_thread(next, gen, _sentinel)
|
||||
|
|
@ -8315,6 +8672,8 @@ async def _anthropic_plain_stream(
|
|||
if _error_event is not None:
|
||||
yield _error_event
|
||||
return
|
||||
finally:
|
||||
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
|
||||
|
||||
stop_reason = openai_finish_to_anthropic_stop(captured_finish_reason, had_tool_calls = False)
|
||||
for line in emitter.finish(stop_reason = stop_reason, stop_sequence = None):
|
||||
|
|
@ -9206,7 +9565,7 @@ async def _openai_passthrough_stream(
|
|||
except Exception:
|
||||
pass
|
||||
_tracker.__exit__(None, None, None)
|
||||
return StreamingResponse(
|
||||
return _SameTaskStreamingResponse(
|
||||
iter(()),
|
||||
media_type = "text/event-stream",
|
||||
headers = {
|
||||
|
|
@ -9257,6 +9616,29 @@ async def _openai_passthrough_stream(
|
|||
_await_disconnect_then_close(request, resp, cancel_event)
|
||||
)
|
||||
monitor_done = False
|
||||
saw_finish_reason = False
|
||||
saw_done = False
|
||||
saw_stream_error = False
|
||||
saw_tool_call_delta = False
|
||||
last_chunk_id = completion_id
|
||||
last_chunk_model = model_name
|
||||
last_chunk_created = int(time.time())
|
||||
|
||||
def _synthetic_finish_line() -> str:
|
||||
finish_reason = "tool_calls" if saw_tool_call_delta else "stop"
|
||||
chunk = ChatCompletionChunk(
|
||||
id = last_chunk_id,
|
||||
created = last_chunk_created,
|
||||
model = last_chunk_model,
|
||||
choices = [
|
||||
ChunkChoice(
|
||||
delta = ChoiceDelta(),
|
||||
finish_reason = finish_reason,
|
||||
)
|
||||
],
|
||||
)
|
||||
return f"data: {chunk.model_dump_json(exclude_none = True)}"
|
||||
|
||||
try:
|
||||
lines_iter = resp.aiter_lines()
|
||||
async for raw_line in _aiter_llama_stream_items(
|
||||
|
|
@ -9270,23 +9652,117 @@ async def _openai_passthrough_stream(
|
|||
continue
|
||||
if not raw_line.startswith("data: "):
|
||||
continue
|
||||
data_text = raw_line[6:].strip()
|
||||
if data_text == "[DONE]":
|
||||
saw_done = True
|
||||
if (
|
||||
not saw_finish_reason
|
||||
and not saw_stream_error
|
||||
and not cancel_event.is_set()
|
||||
):
|
||||
finish_line = _synthetic_finish_line()
|
||||
_monitor_openai_sse_line(
|
||||
monitor_id,
|
||||
finish_line,
|
||||
llama_backend.context_length,
|
||||
)
|
||||
yield finish_line + "\n\n"
|
||||
saw_finish_reason = True
|
||||
_monitor_openai_sse_line(
|
||||
monitor_id,
|
||||
raw_line,
|
||||
llama_backend.context_length,
|
||||
)
|
||||
yield raw_line + "\n\n"
|
||||
monitor_done = True
|
||||
break
|
||||
# Honor parallel_tool_calls=false (best-effort): drop tool_call
|
||||
# deltas with index>=1 so only the first call streams. Only
|
||||
# lines carrying tool_calls are reparsed; everything else is
|
||||
# relayed byte-for-byte.
|
||||
if payload.parallel_tool_calls is False and '"tool_calls"' in raw_line:
|
||||
raw_line = _cap_parallel_tool_calls_sse_line(raw_line)
|
||||
data_text = raw_line[6:].strip()
|
||||
try:
|
||||
chunk_data = json.loads(data_text)
|
||||
except json.JSONDecodeError:
|
||||
chunk_data = None
|
||||
if isinstance(chunk_data, dict):
|
||||
if isinstance(chunk_data.get("id"), str):
|
||||
last_chunk_id = chunk_data["id"]
|
||||
if isinstance(chunk_data.get("model"), str):
|
||||
last_chunk_model = chunk_data["model"]
|
||||
if isinstance(chunk_data.get("created"), int):
|
||||
last_chunk_created = chunk_data["created"]
|
||||
choices = chunk_data.get("choices")
|
||||
if isinstance(choices, list) and choices:
|
||||
choice = choices[0]
|
||||
if isinstance(choice, dict):
|
||||
if choice.get("finish_reason"):
|
||||
saw_finish_reason = True
|
||||
delta = choice.get("delta")
|
||||
if isinstance(delta, dict) and delta.get("tool_calls"):
|
||||
saw_tool_call_delta = True
|
||||
# Detect an upstream error chunk independently of API
|
||||
# monitoring: when monitor_id is None (skip_api_monitor),
|
||||
# _monitor_openai_sse_line returns before inspecting the
|
||||
# error, so without this the synthetic-finish guard would
|
||||
# emit a successful finish_reason after a failed stream.
|
||||
if _monitor_openai_error_message(chunk_data):
|
||||
saw_stream_error = True
|
||||
monitor_event = _monitor_openai_sse_line(
|
||||
monitor_id,
|
||||
raw_line,
|
||||
llama_backend.context_length,
|
||||
)
|
||||
if monitor_event == "error":
|
||||
saw_stream_error = True
|
||||
# If a trailing usage-only chunk (include_usage) arrives before
|
||||
# any finish chunk, emit the synthetic finish first so the order
|
||||
# stays finish -> usage -> [DONE], matching the other streams.
|
||||
if (
|
||||
isinstance(chunk_data, dict)
|
||||
and chunk_data.get("usage")
|
||||
and not (
|
||||
isinstance(chunk_data.get("choices"), list) and chunk_data["choices"]
|
||||
)
|
||||
and not saw_finish_reason
|
||||
and not saw_stream_error
|
||||
and not cancel_event.is_set()
|
||||
):
|
||||
finish_line = _synthetic_finish_line()
|
||||
_monitor_openai_sse_line(
|
||||
monitor_id, finish_line, llama_backend.context_length
|
||||
)
|
||||
yield finish_line + "\n\n"
|
||||
saw_finish_reason = True
|
||||
# Relay verbatim to preserve llama-server's native id,
|
||||
# finish_reason, delta.tool_calls, and usage chunks.
|
||||
yield raw_line + "\n\n"
|
||||
if monitor_event == "done" or raw_line[6:].strip() == "[DONE]":
|
||||
if monitor_event == "done":
|
||||
monitor_done = True
|
||||
break
|
||||
if not saw_done and not saw_stream_error and not cancel_event.is_set():
|
||||
# Synthesize a finish chunk only if one was not already
|
||||
# emitted (e.g. before a trailing usage-only chunk), but
|
||||
# always close with [DONE] whenever the upstream omitted it,
|
||||
# so the stream ends on the [DONE] sentinel either way.
|
||||
if not saw_finish_reason:
|
||||
finish_line = _synthetic_finish_line()
|
||||
_monitor_openai_sse_line(
|
||||
monitor_id,
|
||||
finish_line,
|
||||
llama_backend.context_length,
|
||||
)
|
||||
yield finish_line + "\n\n"
|
||||
done_line = "data: [DONE]"
|
||||
_monitor_openai_sse_line(
|
||||
monitor_id,
|
||||
done_line,
|
||||
llama_backend.context_length,
|
||||
)
|
||||
yield done_line + "\n\n"
|
||||
monitor_done = True
|
||||
if not monitor_done:
|
||||
api_monitor.finish(
|
||||
monitor_id,
|
||||
|
|
@ -9322,7 +9798,24 @@ async def _openai_passthrough_stream(
|
|||
)
|
||||
_tracker.__exit__(None, None, None)
|
||||
|
||||
return _sse_streaming_response(_stream())
|
||||
async def _unstarted_cleanup() -> None:
|
||||
# Client disconnected before the body stream started, so _stream()'s
|
||||
# finally never ran. Release the eagerly-opened upstream resp/client
|
||||
# and the cancel-registry entry here; the watchers and line iterator
|
||||
# are created inside _stream(), so there is nothing else to close.
|
||||
await _aclose_stream_resources(resp = resp, client = client)
|
||||
_tracker.__exit__(None, None, None)
|
||||
|
||||
return _SameTaskStreamingResponse(
|
||||
_stream(),
|
||||
media_type = "text/event-stream",
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "close",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
unstarted_cleanup = _unstarted_cleanup,
|
||||
)
|
||||
except BaseException:
|
||||
_tracker.__exit__(None, None, None)
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -893,9 +893,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 +924,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 +938,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 +983,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
|
||||
|
||||
|
|
@ -1158,7 +1171,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
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ def test_python_bypass_uses_bypass_preexec_and_bypass_env(captured_popen, monkey
|
|||
assert captured_popen["kwargs"]["preexec_fn"] is tools._bypass_preexec
|
||||
env = captured_popen["kwargs"]["env"]
|
||||
assert env.get("HOSTVAR") == "benign-xyz"
|
||||
assert env.get("PYTHONIOENCODING") == "utf-8"
|
||||
assert "HF_TOKEN" not in env
|
||||
|
||||
|
||||
|
|
@ -151,9 +152,12 @@ def test_bash_blocklist_skipped_when_bypassed(captured_popen):
|
|||
|
||||
|
||||
@_POSIX_ONLY
|
||||
def test_bash_bypass_uses_bypass_preexec(captured_popen):
|
||||
def test_bash_bypass_uses_bypass_preexec(captured_popen, monkeypatch):
|
||||
# bypass inherits benign host vars; clear so we assert _bash_exec adds none.
|
||||
monkeypatch.delenv("PYTHONIOENCODING", raising = False)
|
||||
_bash_exec("echo hi", None, 5, "t", disable_sandbox = True)
|
||||
assert captured_popen["kwargs"]["preexec_fn"] is tools._bypass_preexec
|
||||
assert "PYTHONIOENCODING" not in captured_popen["kwargs"]["env"]
|
||||
|
||||
|
||||
# ── real end-to-end python execution under bypass ───────────────────
|
||||
|
|
|
|||
33
studio/backend/tests/test_exec_utf8.py
Normal file
33
studio/backend/tests/test_exec_utf8.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
"""_python_exec must round-trip non-ASCII output end to end.
|
||||
|
||||
Model-written code routinely contains non-ASCII (arrows, CJK, emoji). The temp
|
||||
script and the child's stdout pipe both have to be UTF-8 or it crashes/garbles
|
||||
on Windows, whose default codec is cp1252. Mirrors the report in
|
||||
unslothai/unsloth#6489. The child is ``python`` with PYTHONIOENCODING=utf-8, so
|
||||
it emits UTF-8 on every OS; this proves the round-trip on a UTF-8 host and
|
||||
guards against a regression to the OS default codec.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BACKEND_ROOT))
|
||||
|
||||
from core.inference.tools import _python_exec
|
||||
|
||||
# Arrow, em-dash, accent, CJK, check mark, astral-plane emoji -- none encodable
|
||||
# in cp1252, so the OS default codec would raise on write or read.
|
||||
_UNICODE = "café — 数字 → ✓ 😀"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("disable_sandbox", [False, True])
|
||||
def test_python_exec_round_trips_non_ascii(disable_sandbox):
|
||||
out = _python_exec(f"print({_UNICODE!r})", disable_sandbox = disable_sandbox)
|
||||
assert _UNICODE in out, repr(out)
|
||||
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
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -1390,6 +1464,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 +1772,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 +2255,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 +2273,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 +2416,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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -445,20 +445,40 @@ def test_pre_import_gate_is_transformers_free():
|
|||
import utils.security.file_security as fs
|
||||
import utils.security.consent as consent
|
||||
|
||||
for m in list(_sys.modules):
|
||||
if m == "transformers" or m.startswith("transformers.") or m == "utils.models.model_config":
|
||||
def _is_gated_module(name: str) -> bool:
|
||||
return (
|
||||
name == "transformers"
|
||||
or name.startswith("transformers.")
|
||||
or name == "utils.models.model_config"
|
||||
)
|
||||
|
||||
# Snapshot then remove the modules so we can assert the gate does not re-import them.
|
||||
# Restore the originals afterwards (finally): popping utils.models.model_config without
|
||||
# restoring it makes a later importer get a fresh instance, so tests that patched the
|
||||
# first instance (e.g. test_vision_cache) miss and hit the real network path.
|
||||
_saved = {m: _sys.modules[m] for m in list(_sys.modules) if _is_gated_module(m)}
|
||||
for m in _saved:
|
||||
_sys.modules.pop(m, None)
|
||||
|
||||
try:
|
||||
with patch.object(fs, "_fetch_security_status", return_value = None):
|
||||
fs.evaluate_file_security("nvidia/Nemotron-H-8B", load_subdirs = ())
|
||||
with patch.object(
|
||||
consent, "_load_remote_code_configs", return_value = [{"model_type": "nemotron_h"}]
|
||||
):
|
||||
from utils.security import evaluate_remote_code_consent_for_targets
|
||||
evaluate_remote_code_consent_for_targets(
|
||||
["nvidia/Nemotron-H-8B"], trust_remote_code = True
|
||||
)
|
||||
|
||||
assert "transformers" not in _sys.modules
|
||||
assert "utils.models.model_config" not in _sys.modules
|
||||
finally:
|
||||
# Drop anything the gate imported, then rebind the original module objects so later
|
||||
# tests see the same instances they captured at import time.
|
||||
for m in [m for m in list(_sys.modules) if _is_gated_module(m) and m not in _saved]:
|
||||
_sys.modules.pop(m, None)
|
||||
|
||||
with patch.object(fs, "_fetch_security_status", return_value = None):
|
||||
fs.evaluate_file_security("nvidia/Nemotron-H-8B", load_subdirs = ())
|
||||
with patch.object(
|
||||
consent, "_load_remote_code_configs", return_value = [{"model_type": "nemotron_h"}]
|
||||
):
|
||||
from utils.security import evaluate_remote_code_consent_for_targets
|
||||
evaluate_remote_code_consent_for_targets(["nvidia/Nemotron-H-8B"], trust_remote_code = True)
|
||||
|
||||
assert "transformers" not in _sys.modules
|
||||
assert "utils.models.model_config" not in _sys.modules
|
||||
_sys.modules.update(_saved)
|
||||
|
||||
|
||||
def test_pre_import_gate_skips_subdir_computation():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,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 +184,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"
|
||||
|
|
@ -191,7 +250,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],
|
||||
|
|
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -182,7 +182,9 @@ function RootLayout() {
|
|||
chatRuntime.setActiveThreadId(null);
|
||||
chatRuntime.setActiveProjectId(null);
|
||||
chatRuntime.setIncognito(false);
|
||||
if (chatRuntime.pendingSelection) chatRuntime.abandonStagedModel();
|
||||
// Detach the staging UI but keep any in-flight download running, like Hub.
|
||||
if (chatRuntime.pendingSelection)
|
||||
chatRuntime.abandonStagedModel({ keepDownload: true });
|
||||
void navigate({
|
||||
to: "/chat",
|
||||
search: { new: crypto.randomUUID() },
|
||||
|
|
@ -205,7 +207,10 @@ function RootLayout() {
|
|||
chatRuntime.setActiveProjectId(null);
|
||||
chatRuntime.setActiveThreadId(null);
|
||||
chatRuntime.setIncognito(false);
|
||||
if (chatRuntime.pendingSelection) chatRuntime.abandonStagedModel();
|
||||
// Leaving chat must not kill an in-flight download: detach the staging UI
|
||||
// but keep the transfer running in the manager, like a Hub download.
|
||||
if (chatRuntime.pendingSelection)
|
||||
chatRuntime.abandonStagedModel({ keepDownload: true });
|
||||
}, [isChatRoute]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -253,6 +256,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();
|
||||
|
|
@ -265,6 +281,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);
|
||||
|
|
@ -1348,7 +1374,7 @@ export function AppSidebar() {
|
|||
)}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="relative group-data-[collapsible=icon]:px-0">
|
||||
<SidebarFooter className="relative pt-3 pb-4 group-data-[collapsible=icon]:px-0">
|
||||
{/* 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. */}
|
||||
|
|
@ -1359,7 +1385,54 @@ export function AppSidebar() {
|
|||
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>
|
||||
|
|
@ -1381,11 +1454,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
|
||||
|
|
|
|||
|
|
@ -2336,7 +2336,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 +2345,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 +2391,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",
|
||||
)}
|
||||
|
|
@ -3362,7 +3367,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 {
|
||||
|
|
@ -18,6 +22,10 @@ import {
|
|||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { useLatestRef } from "@/features/hub/hooks/use-latest-ref";
|
||||
import {
|
||||
DOWNLOAD_KIND,
|
||||
downloadManager,
|
||||
} from "@/features/hub/download-manager";
|
||||
import {
|
||||
type NativeIntent,
|
||||
NativeModelChip,
|
||||
|
|
@ -1093,6 +1101,11 @@ export function ChatPage({
|
|||
const abandonStaged = useCallback(() => {
|
||||
useChatRuntimeStore.getState().abandonStagedModel();
|
||||
}, []);
|
||||
// Detach a staged pick on navigation without cancelling its download: the
|
||||
// transfer keeps running in the manager and lands in cache, like Hub.
|
||||
const detachStaged = useCallback(() => {
|
||||
useChatRuntimeStore.getState().abandonStagedModel({ keepDownload: true });
|
||||
}, []);
|
||||
// Tracks whether the chat page is still mounted, so a staged-load failure that
|
||||
// resolves after the user left chat doesn't resurrect the abandoned pick.
|
||||
const mountedRef = useRef(true);
|
||||
|
|
@ -1266,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();
|
||||
|
|
@ -1620,8 +1638,8 @@ export function ChatPage({
|
|||
const prev = prevChatContextRef.current;
|
||||
prevChatContextRef.current = chatContextKey;
|
||||
if (prev === null || prev === chatContextKey) return;
|
||||
abandonStaged();
|
||||
}, [chatContextKey, abandonStaged]);
|
||||
detachStaged();
|
||||
}, [chatContextKey, detachStaged]);
|
||||
|
||||
const hasActiveModel = Boolean(inferenceParams.checkpoint);
|
||||
// Load immediately, or — when "Load on selection" is off — stage the pick so
|
||||
|
|
@ -1639,25 +1657,81 @@ export function ChatPage({
|
|||
(!hasGgufSource(selection) && !wantManagerDownload) ||
|
||||
(store.loadOnSelection && selection.isDownloaded)
|
||||
) {
|
||||
// Abandon any staged pick first so its edited knobs (e.g. a custom
|
||||
// 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.
|
||||
abandonStaged();
|
||||
await selectModel(selection);
|
||||
// reads customContextLength before checking the target is GGUF. Detach
|
||||
// (not abandon) keeps its download running.
|
||||
detachStaged();
|
||||
// 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;
|
||||
}
|
||||
// Refuse staging while a load is in flight (it would be silently dropped);
|
||||
// the immediate-load branch above is already guarded in selectModel.
|
||||
// Loads can't queue behind each other, but a download is independent: if
|
||||
// the pick needs downloading, start it in the manager so it runs alongside
|
||||
// the load. Nothing to download (already on device) just waits.
|
||||
if (store.modelLoading) {
|
||||
toast.info("Another model is already loading", {
|
||||
description: "Wait for it to finish or cancel it first.",
|
||||
});
|
||||
// Both an uncached non-GGUF snapshot (wantManagerDownload) and an
|
||||
// uncached remote GGUF quant download through the manager, so either can
|
||||
// run in the background while another model loads. wantManagerDownload
|
||||
// excludes GGUF by design, so the GGUF case is checked separately.
|
||||
const wantBackgroundDownload =
|
||||
wantManagerDownload ||
|
||||
(selection.source === "hub" &&
|
||||
hasGgufSource(selection) &&
|
||||
!selection.isDownloaded);
|
||||
// The model currently loading already downloads as part of its own load
|
||||
// (the /load flow fetches before setting the checkpoint), so re-picking
|
||||
// it must not kick off a second transfer against the same cache.
|
||||
const isLoadingThisPick =
|
||||
!!loadingModel &&
|
||||
normalizeModelRef(loadingModel.id) ===
|
||||
normalizeModelRef(selection.id) &&
|
||||
(loadingModel.ggufVariant ?? null) === (selection.ggufVariant ?? null);
|
||||
if (isLoadingThisPick) {
|
||||
toast.info("This model is already loading", {
|
||||
description: "It's downloading as part of the load in progress.",
|
||||
});
|
||||
} else if (wantBackgroundDownload) {
|
||||
// Only claim the download started once a job is actually created. A
|
||||
// transport conflict records state that is only resolvable from the
|
||||
// Hub download card, so point the user there instead of showing a
|
||||
// success toast for a transfer that never began; "busy" and "error"
|
||||
// already surface their own toasts.
|
||||
const outcome = await downloadManager.requestStart({
|
||||
kind: DOWNLOAD_KIND.MODEL,
|
||||
repoId: selection.id,
|
||||
variant: selection.ggufVariant ?? null,
|
||||
expectedBytes: selection.expectedBytes ?? 0,
|
||||
});
|
||||
if (outcome === "started") {
|
||||
toast.info("Downloading in the background", {
|
||||
description:
|
||||
"It'll be ready to load once the current model finishes.",
|
||||
});
|
||||
} else if (outcome === "conflict") {
|
||||
toast.info("Resume this download from the Hub", {
|
||||
description:
|
||||
"An earlier partial download used a different transport. Open the Hub tab to resume or restart it.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast.info("Another model is already loading", {
|
||||
description: "Wait for it to finish or cancel it first.",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Tear down any existing staged pick first so its in-flight download is
|
||||
// cancelled, not left running after we rebind to the new pick. With the
|
||||
// toggle on, autoLoad downloads silently then loads; off stages for the sheet.
|
||||
abandonStaged();
|
||||
// Detach the prior staged pick (keeping its download) before rebinding, so
|
||||
// a second pick downloads alongside the first instead of cancelling it.
|
||||
detachStaged();
|
||||
store.stageModel({
|
||||
id: selection.id,
|
||||
isLora: selection.isLora,
|
||||
|
|
@ -1670,7 +1744,7 @@ export function ChatPage({
|
|||
autoLoad: store.loadOnSelection,
|
||||
});
|
||||
},
|
||||
[abandonStaged, selectModel],
|
||||
[detachStaged, selectModel, loadingModel],
|
||||
);
|
||||
const loadNativeModelIntent = useCallback(
|
||||
async (intent: NativeIntent, loadingDescription: string) => {
|
||||
|
|
@ -2452,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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -778,9 +783,10 @@ type ChatRuntimeStore = {
|
|||
/** Stage a pick for a deferred load: revert knobs to the loaded baseline,
|
||||
* record the selection, and open the settings sheet. */
|
||||
stageModel: (selection: PendingModelSelection) => void;
|
||||
/** Abandon a staged pick without loading: revert the knobs to the loaded
|
||||
* baseline and clear the pending selection. */
|
||||
abandonStagedModel: () => void;
|
||||
/** Abandon a staged pick without loading: revert knobs to the loaded baseline
|
||||
* and clear the pending selection. Cancels its in-flight download too, unless
|
||||
* `keepDownload` is set (navigation keeps the transfer running, like Hub). */
|
||||
abandonStagedModel: (opts?: { keepDownload?: boolean }) => void;
|
||||
setCustomContextLength: (v: number | null) => void;
|
||||
setChatTemplateOverride: (template: string | null) => void;
|
||||
setPendingAudio: (base64: string, name: string) => void;
|
||||
|
|
@ -1527,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 });
|
||||
|
|
@ -1544,15 +1560,9 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
// Refuse staging mid-load: post-load cleanup would silently drop the queued
|
||||
// pick. stageOrLoad toasts first for callers that can.
|
||||
if (get().modelLoading) return;
|
||||
// Rebinding to a new pick keeps the prior pick's download running so the
|
||||
// user can queue multiple downloads at once (Hub-style).
|
||||
set((s) => {
|
||||
if (
|
||||
s.pendingSelection &&
|
||||
(s.pendingSelection.id !== selection.id ||
|
||||
(s.pendingSelection.ggufVariant ?? null) !==
|
||||
(selection.ggufVariant ?? null))
|
||||
) {
|
||||
cancelStagedModelDownload(s.pendingSelection);
|
||||
}
|
||||
return {
|
||||
...loadedBaselineSettings(s),
|
||||
pendingSelection: selection,
|
||||
|
|
@ -1566,14 +1576,13 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
};
|
||||
});
|
||||
},
|
||||
abandonStagedModel: () => {
|
||||
abandonStagedModel: (opts) => {
|
||||
const { pendingSelection } = get();
|
||||
if (!pendingSelection) return;
|
||||
// Cancel the staged pick's in-flight download so it doesn't keep running
|
||||
// after the staging UI is gone. Centralized here so every abandon path
|
||||
// (sheet close, thread switch, route exit, new chat) cancels it, including
|
||||
// root-level callers that have no access to the useRepoDownload hook.
|
||||
cancelStagedModelDownload(pendingSelection);
|
||||
// Cancel the staged pick's in-flight download (centralized for every abandon
|
||||
// path: sheet close, thread switch, route exit, new chat). `keepDownload`
|
||||
// opts out so navigation leaves the transfer running, like a Hub download.
|
||||
if (!opts?.keepDownload) cancelStagedModelDownload(pendingSelection);
|
||||
set((s) => ({ ...loadedBaselineSettings(s), pendingSelection: null }));
|
||||
},
|
||||
setCustomContextLength: (customContextLength) => set({ customContextLength }),
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,24 +80,50 @@ async function activeSiblingTransport(
|
|||
return null;
|
||||
}
|
||||
|
||||
// Outcome of a start request so callers can tell whether a transfer for this
|
||||
// exact request is actually live before telling the user it began. "started"
|
||||
// means a running/cancelling job exists for this key (a fresh start or an
|
||||
// already-active one). "conflict" means a transport partial conflict was
|
||||
// recorded and must be resolved from the Hub download card; "busy" means the
|
||||
// repo is occupied by a sibling variant/snapshot/pending start that is not this
|
||||
// transfer; "error" means the start failed or was refused.
|
||||
export type DownloadStartOutcome = "started" | "conflict" | "busy" | "error";
|
||||
|
||||
// A start can no-op without throwing: the backend can refuse it (startJob
|
||||
// finalizes "error"), startJob's peer guard can skip it, or
|
||||
// hasActiveOrPendingStart can trip on a snapshot/peer/pending that is not this
|
||||
// request. Derive the outcome from the actual job state of this exact key so
|
||||
// callers never claim a download began when it did not.
|
||||
function isJobActiveFor(req: DownloadRequest): boolean {
|
||||
const job = getState().jobs[jobKeyOf(req.kind, req.repoId, req.variant)];
|
||||
return Boolean(job && ACTIVE_STATES.has(job.state));
|
||||
}
|
||||
|
||||
async function runWithPendingStartGuard(
|
||||
req: DownloadRequest,
|
||||
action: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
action: () => Promise<DownloadStartOutcome>,
|
||||
): Promise<DownloadStartOutcome> {
|
||||
const startKey = pendingStartKey(req);
|
||||
if (hasActiveOrPendingStart(req)) return;
|
||||
// Already active or pending for the repo: only report "started" when this
|
||||
// exact request is the live transfer; a peer/snapshot/pending start has not.
|
||||
if (hasActiveOrPendingStart(req)) {
|
||||
return isJobActiveFor(req) ? "started" : "busy";
|
||||
}
|
||||
runtimeRegistry.pendingStartRepoKeys.add(startKey);
|
||||
try {
|
||||
await action();
|
||||
return await action();
|
||||
} catch (error) {
|
||||
reportConflictStartError(error);
|
||||
return "error";
|
||||
} finally {
|
||||
runtimeRegistry.pendingStartRepoKeys.delete(startKey);
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestStart(req: DownloadRequest): Promise<void> {
|
||||
await runWithPendingStartGuard(req, async () => {
|
||||
export async function requestStart(
|
||||
req: DownloadRequest,
|
||||
): Promise<DownloadStartOutcome> {
|
||||
return runWithPendingStartGuard(req, async () => {
|
||||
let mode: TransportMode = getTransportMode();
|
||||
try {
|
||||
mode = await effectiveTransportMode(mode);
|
||||
|
|
@ -119,7 +145,7 @@ export async function requestStart(req: DownloadRequest): Promise<void> {
|
|||
? "This repository is currently downloading with Xet. Switch to Xet or wait for it to finish."
|
||||
: "This repository is currently downloading with HTTP. Switch to HTTP or wait for it to finish.",
|
||||
});
|
||||
return;
|
||||
return "busy";
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Active download transport check failed.", err);
|
||||
|
|
@ -139,7 +165,7 @@ export async function requestStart(req: DownloadRequest): Promise<void> {
|
|||
},
|
||||
pending: req,
|
||||
});
|
||||
return;
|
||||
return "conflict";
|
||||
}
|
||||
if (status.has_partial && !status.last_transport) {
|
||||
toast.info("Restarting this download", {
|
||||
|
|
@ -163,7 +189,7 @@ export async function requestStart(req: DownloadRequest): Promise<void> {
|
|||
"Starting with HTTP so an existing partial is not discarded. Switch transport to retry with Xet.",
|
||||
});
|
||||
await startJob(req, { useXet: false });
|
||||
return;
|
||||
return isJobActiveFor(req) ? "started" : "error";
|
||||
}
|
||||
toast.warning("Couldn't verify existing partial download", {
|
||||
description:
|
||||
|
|
@ -171,6 +197,7 @@ export async function requestStart(req: DownloadRequest): Promise<void> {
|
|||
});
|
||||
}
|
||||
await startJob(req, { useXet: mode === TRANSPORT.XET });
|
||||
return isJobActiveFor(req) ? "started" : "error";
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -178,22 +205,24 @@ export function resumeConflict(conflictKey: string): void {
|
|||
const entry = getState().conflicts[conflictKey];
|
||||
if (!entry) return;
|
||||
setConflict(conflictKey, null);
|
||||
void runWithPendingStartGuard(entry.pending, () =>
|
||||
startJob(entry.pending, {
|
||||
void runWithPendingStartGuard(entry.pending, async () => {
|
||||
await startJob(entry.pending, {
|
||||
useXet: entry.info.previous === TRANSPORT.XET,
|
||||
}),
|
||||
);
|
||||
});
|
||||
return "started";
|
||||
});
|
||||
}
|
||||
|
||||
export function restartConflict(conflictKey: string): void {
|
||||
const entry = getState().conflicts[conflictKey];
|
||||
if (!entry) return;
|
||||
setConflict(conflictKey, null);
|
||||
void runWithPendingStartGuard(entry.pending, () =>
|
||||
startJob(entry.pending, {
|
||||
void runWithPendingStartGuard(entry.pending, async () => {
|
||||
await startJob(entry.pending, {
|
||||
useXet: entry.info.next === TRANSPORT.XET,
|
||||
}),
|
||||
);
|
||||
});
|
||||
return "started";
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelConflict(conflictKey: string): void {
|
||||
|
|
|
|||
|
|
@ -120,8 +120,10 @@ export function useRepoDownload(config: RepoDownloadConfig): DownloadJob {
|
|||
);
|
||||
|
||||
const requestStartDownload = useCallback(
|
||||
(variant: string | null, expectedBytes: number) => {
|
||||
return downloadManager.requestStart({
|
||||
async (variant: string | null, expectedBytes: number) => {
|
||||
// This surface renders the conflict resolver (transportConflict), so the
|
||||
// start outcome is handled by the card UI; the awaited result is ignored.
|
||||
await downloadManager.requestStart({
|
||||
kind,
|
||||
repoId,
|
||||
variant,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -740,6 +749,23 @@ export function ModelsPage() {
|
|||
() => (isDiscoverTab ? [] : tokenizeQuery(deferredDebouncedQuery)),
|
||||
[isDiscoverTab, deferredDebouncedQuery],
|
||||
);
|
||||
// Hide infra models (e.g. the RAG embedder bge-small-en-v1.5) from the On
|
||||
// Device list like Discover, but reveal a row when a query matches it so the
|
||||
// user can confirm it is already downloaded.
|
||||
const isVisibleInventoryRow = useCallback(
|
||||
(row: CachedInventoryRow | LocalInventoryRow) =>
|
||||
// Local rows can have a null repoId and an id that is a hash rather than
|
||||
// the file path/name, so also check path/title (the backend's
|
||||
// _is_hidden_model checks the on-disk path for the same reason).
|
||||
!isHiddenModelId(
|
||||
row.id,
|
||||
row.repoId,
|
||||
row.kind !== "cache" ? row.path : undefined,
|
||||
row.kind !== "cache" ? row.title : undefined,
|
||||
) ||
|
||||
(inventoryTokens.length > 0 && inventoryRowMatches(row, inventoryTokens)),
|
||||
[inventoryTokens],
|
||||
);
|
||||
// Format filter is a deliberate scope narrowing, so hard-filter it out. The
|
||||
// text query instead drives dim-not-filter on On Device (see ModelsCatalog) so
|
||||
// selection survives typing; matching rows are partitioned to the top.
|
||||
|
|
@ -748,12 +774,22 @@ export function ModelsPage() {
|
|||
partitionByMatch(
|
||||
effectiveCachedRows.filter(
|
||||
(row) =>
|
||||
// Hidden-model filtering is model-only; datasets bypass it (and the
|
||||
// format filter) the way Discover does, so a dataset whose
|
||||
// id/title/path happens to contain an infra needle is not dropped.
|
||||
isDatasetMode ||
|
||||
matchesFormat(row.modelFormat, deferredFormatFilter),
|
||||
(matchesFormat(row.modelFormat, deferredFormatFilter) &&
|
||||
isVisibleInventoryRow(row)),
|
||||
),
|
||||
inventoryTokens,
|
||||
),
|
||||
[effectiveCachedRows, isDatasetMode, deferredFormatFilter, inventoryTokens],
|
||||
[
|
||||
effectiveCachedRows,
|
||||
isDatasetMode,
|
||||
deferredFormatFilter,
|
||||
inventoryTokens,
|
||||
isVisibleInventoryRow,
|
||||
],
|
||||
);
|
||||
|
||||
const filteredLocalRows = useMemo(
|
||||
|
|
@ -761,12 +797,42 @@ export function ModelsPage() {
|
|||
partitionByMatch(
|
||||
effectiveLocalRows.filter(
|
||||
(row) =>
|
||||
// Hidden-model filtering is model-only; datasets bypass it (and the
|
||||
// format filter) the way Discover does, so a dataset whose
|
||||
// id/title/path happens to contain an infra needle is not dropped.
|
||||
isDatasetMode ||
|
||||
matchesFormat(row.modelFormat, deferredFormatFilter),
|
||||
(matchesFormat(row.modelFormat, deferredFormatFilter) &&
|
||||
isVisibleInventoryRow(row)),
|
||||
),
|
||||
inventoryTokens,
|
||||
),
|
||||
[effectiveLocalRows, isDatasetMode, deferredFormatFilter, inventoryTokens],
|
||||
[
|
||||
effectiveLocalRows,
|
||||
isDatasetMode,
|
||||
deferredFormatFilter,
|
||||
inventoryTokens,
|
||||
isVisibleInventoryRow,
|
||||
],
|
||||
);
|
||||
|
||||
// Header tallies exclude infra/hidden models so the count matches the On
|
||||
// Device list (a fresh install with only the bge embedder cached reads 0,
|
||||
// not 1 over an empty list). Reuse isVisibleInventoryRow so a hidden row
|
||||
// revealed by an active search is counted too, and datasets (never infra)
|
||||
// keep their full count, mirroring the row filter above.
|
||||
const visibleCachedCount = useMemo(
|
||||
() =>
|
||||
effectiveCachedRows.filter(
|
||||
(row) => isDatasetMode || isVisibleInventoryRow(row),
|
||||
).length,
|
||||
[effectiveCachedRows, isDatasetMode, isVisibleInventoryRow],
|
||||
);
|
||||
const visibleLocalCount = useMemo(
|
||||
() =>
|
||||
effectiveLocalRows.filter(
|
||||
(row) => isDatasetMode || isVisibleInventoryRow(row),
|
||||
).length,
|
||||
[effectiveLocalRows, isDatasetMode, isVisibleInventoryRow],
|
||||
);
|
||||
|
||||
const filterResetSignature = useMemo(
|
||||
|
|
@ -850,7 +916,7 @@ export function ModelsPage() {
|
|||
filteredCachedRows,
|
||||
filteredLocalRows,
|
||||
results: selectionResults,
|
||||
accessToken: debouncedHfToken || undefined,
|
||||
accessToken: apiHfToken,
|
||||
online,
|
||||
});
|
||||
|
||||
|
|
@ -1026,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(() => {
|
||||
|
|
@ -1315,15 +1402,15 @@ export function ModelsPage() {
|
|||
return (
|
||||
<HubListHeader
|
||||
title="On device"
|
||||
count={effectiveCachedRows.length + effectiveLocalRows.length}
|
||||
count={visibleCachedCount + visibleLocalCount}
|
||||
view={allModelsView}
|
||||
onViewChange={setAllModelsView}
|
||||
actions={sortControl}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
effectiveCachedRows.length,
|
||||
effectiveLocalRows.length,
|
||||
visibleCachedCount,
|
||||
visibleLocalCount,
|
||||
allModelsView,
|
||||
setAllModelsView,
|
||||
inventorySort,
|
||||
|
|
@ -1337,8 +1424,8 @@ export function ModelsPage() {
|
|||
<div className="hub-page flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden bg-background">
|
||||
<HubTopBar>
|
||||
<ModelsHeader
|
||||
cachedCount={effectiveCachedRows.length}
|
||||
localCount={effectiveLocalRows.length}
|
||||
cachedCount={visibleCachedCount}
|
||||
localCount={visibleLocalCount}
|
||||
isDataset={isDatasetMode}
|
||||
gpuLabel={gpuLabel}
|
||||
ramLabel={ramLabel}
|
||||
|
|
|
|||
|
|
@ -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")}>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const zhCN = {
|
|||
},
|
||||
shell: {
|
||||
accountMenu: "{name} 账号菜单",
|
||||
updateAvailable: "有可用更新",
|
||||
aria: {
|
||||
home: "Unsloth 首页",
|
||||
closeSidebar: "关闭侧边栏",
|
||||
|
|
|
|||
25
studio/frontend/src/lib/bulb-icon.tsx
Normal file
25
studio/frontend/src/lib/bulb-icon.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// 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 { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
// Shared lightbulb glyph used by the composer thinking toggle and the
|
||||
// reasoning "Thinking..." indicator so both stay in sync. Defaults are
|
||||
// overridable via props.
|
||||
export const BulbIcon = ({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<"svg">) => (
|
||||
<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}
|
||||
{...props}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
|
|
@ -230,12 +230,15 @@ FORCE_COMPILE_DEFAULT_REF = os.environ.get("UNSLOTH_LLAMA_FORCE_COMPILE_REF", "m
|
|||
_MIN_CUDA_MAJOR = 12
|
||||
_MAX_PROBE_CUDA_MAJOR = 19
|
||||
|
||||
# Blackwell sm_120 capability thresholds. A host is Blackwell when its highest
|
||||
# compute capability is at least sm_120; ggml compiles sm_120 only at toolkit
|
||||
# >= 12.8, so an in-release windows-cuda build at or above that already covers
|
||||
# Blackwell, while cuda-12.4 does not and is dropped on a Blackwell host.
|
||||
_BLACKWELL_MIN_SM = 120
|
||||
# Blackwell floor is sm_100: data-center parts (B100/B200 sm_100, B300/GB300
|
||||
# sm_103) sit below consumer Blackwell (RTX 50 sm_120); the family needs toolkit
|
||||
# >= 12.8, except sm_103/sm_121 which need 12.9. (120 here wrongly excluded the
|
||||
# sm_100/103 data-center hosts.)
|
||||
_BLACKWELL_MIN_SM = 100
|
||||
_BLACKWELL_MIN_TOOLKIT = (12, 8)
|
||||
# SMs that need a newer toolkit than the family floor (CUDA 12.9 added native
|
||||
# sm_103/sm_121 targets; 12.8 covers sm_100/101/120).
|
||||
_BLACKWELL_SM_MIN_TOOLKIT = {103: (12, 9), 121: (12, 9)}
|
||||
|
||||
|
||||
def _cuda_runtime_lines_for_major(major: int) -> list[str]:
|
||||
|
|
@ -3418,18 +3421,18 @@ def windows_cuda_attempts(
|
|||
return attempts
|
||||
|
||||
|
||||
def _windows_cuda_attempt_covers_blackwell(attempt: AssetChoice) -> bool:
|
||||
"""True if an in-release windows-cuda attempt yields a Blackwell sm_120
|
||||
capable build. The fork's app-named bundles declare their SM coverage
|
||||
directly; legacy upstream-named bundles instead encode their CUDA toolkit
|
||||
minor in the filename (covers Blackwell at toolkit >= 12.8)."""
|
||||
def _windows_cuda_attempt_covers_blackwell(
|
||||
attempt: AssetChoice, min_toolkit: tuple[int, int] = _BLACKWELL_MIN_TOOLKIT
|
||||
) -> bool:
|
||||
"""True if a windows-cuda attempt is Blackwell-capable (app bundles via
|
||||
declared SMs; legacy upstream bundles via toolkit minor >= min_toolkit:
|
||||
12.8 for the family, 12.9 for sm_103/sm_121)."""
|
||||
if attempt.install_kind != "windows-cuda":
|
||||
return False
|
||||
# Legacy upstream-named bundles encode their toolkit minor; it is the binding
|
||||
# constraint (a 12.4 toolkit cannot offload sm_120 whatever its metadata says).
|
||||
# Legacy bundle: the toolkit minor binds (12.4 cannot offload Blackwell).
|
||||
m = re.search(r"-bin-win-cuda-(\d+)\.(\d+)-x64\.zip$", attempt.name)
|
||||
if m is not None:
|
||||
return (int(m.group(1)), int(m.group(2))) >= _BLACKWELL_MIN_TOOLKIT
|
||||
return (int(m.group(1)), int(m.group(2))) >= min_toolkit
|
||||
# App-named bundles carry no minor and declare their SM coverage directly.
|
||||
return attempt.max_sm is not None and attempt.max_sm >= _BLACKWELL_MIN_SM
|
||||
|
||||
|
|
@ -3439,22 +3442,30 @@ def _host_is_blackwell(host: HostInfo) -> bool:
|
|||
return bool(caps) and int(caps[-1]) >= _BLACKWELL_MIN_SM
|
||||
|
||||
|
||||
def _blackwell_min_toolkit_for_host(host: HostInfo) -> tuple[int, int]:
|
||||
"""Minimum CUDA toolkit this Blackwell host needs: 12.8 for the family,
|
||||
12.9 if any of its SMs is sm_103/sm_121 (no native target before 12.9)."""
|
||||
req = _BLACKWELL_MIN_TOOLKIT
|
||||
for sm in normalize_compute_caps(host.compute_caps):
|
||||
req = max(req, _BLACKWELL_SM_MIN_TOOLKIT.get(int(sm), _BLACKWELL_MIN_TOOLKIT))
|
||||
return req
|
||||
|
||||
|
||||
def _drop_blackwell_incapable_windows_cuda(
|
||||
host: HostInfo, attempts: list[AssetChoice]
|
||||
) -> list[AssetChoice]:
|
||||
"""On a Blackwell host, drop windows-cuda attempts that cannot offload
|
||||
sm_120 (e.g. upstream cuda-12.4, toolkit 12.4). Such a build loads and
|
||||
passes the functional validator but runs the model on a slow non-native
|
||||
path (an RTX 5090 measured 7.1 tok/s vs 551.2 on cuda-13.3), so it must
|
||||
not sit in the fallback chain behind the pin or an in-release cuda13.
|
||||
Non-cuda attempts (windows-cpu, windows-hip, ...) pass through so the
|
||||
host still degrades to an honest CPU install when no CUDA 13 exists."""
|
||||
"""On a Blackwell host, drop windows-cuda attempts that can't offload
|
||||
Blackwell (e.g. cuda-12.4): they load and validate but run a slow non-native
|
||||
path (RTX 5090: 7.1 vs 551.2 tok/s on cuda-13.3). Non-cuda attempts pass
|
||||
through so the host can still fall back to an honest CPU install."""
|
||||
if not _host_is_blackwell(host):
|
||||
return attempts
|
||||
min_toolkit = _blackwell_min_toolkit_for_host(host)
|
||||
return [
|
||||
attempt
|
||||
for attempt in attempts
|
||||
if attempt.install_kind != "windows-cuda" or _windows_cuda_attempt_covers_blackwell(attempt)
|
||||
if attempt.install_kind != "windows-cuda"
|
||||
or _windows_cuda_attempt_covers_blackwell(attempt, min_toolkit)
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1658,7 +1658,7 @@ def run(
|
|||
|
||||
|
||||
# Packages to skip on Windows (require special build steps)
|
||||
WINDOWS_SKIP_PACKAGES = {"open_spiel", "triton_kernels"}
|
||||
WINDOWS_SKIP_PACKAGES = {"triton_kernels"}
|
||||
|
||||
# Packages to skip when torch is unavailable (Intel Mac GGUF-only mode).
|
||||
# These either *are* torch extensions or have unconditional
|
||||
|
|
@ -1675,7 +1675,6 @@ NO_TORCH_SKIP_PACKAGES = {
|
|||
"torchcodec",
|
||||
"torch-c-dlpack-ext",
|
||||
"openai-whisper",
|
||||
"transformers-cfg",
|
||||
"librosa",
|
||||
}
|
||||
|
||||
|
|
@ -2234,25 +2233,25 @@ def install_python_stack() -> int:
|
|||
# 4. Overrides (torchao) -- force-reinstall. The torchao version is chosen to
|
||||
# match the torch installed in the venv so its C++ extensions load (see
|
||||
# _select_torchao_spec). Skip when torch is unavailable (e.g. Intel Mac
|
||||
# GGUF-only mode): torchao requires torch.
|
||||
# GGUF-only mode): torchao requires torch. Also skipped on Windows ROCm
|
||||
# (no working build; see below).
|
||||
if NO_TORCH:
|
||||
_progress("dependency overrides (skipped, no torch)")
|
||||
elif _rocm_windows_torch_installed:
|
||||
# No working Windows ROCm torchao build: it imports an absent c10d backend
|
||||
# and crashes transformers.quantizers. Studio stubs it at runtime, so
|
||||
# installing it only ships a package that crashes on import -- skip it.
|
||||
_progress("dependency overrides (skipped, Windows ROCm)")
|
||||
_safe_print(" Windows ROCm -- skipping torchao (no working build; stubbed at runtime)")
|
||||
else:
|
||||
_progress("dependency overrides")
|
||||
_torch_ver = _probe_installed_torch_version()
|
||||
_torchao_spec = _select_torchao_spec(_torch_ver)
|
||||
_safe_print(f" torch {_torch_ver or 'unknown'} detected -- installing {_torchao_spec}")
|
||||
_override_extra_args: tuple[str, ...] = ()
|
||||
if _rocm_windows_torch_installed:
|
||||
# torchao declares torch as a dependency; without --no-deps uv would
|
||||
# install CPU torch from PyPI, overwriting the AMD ROCm wheels we just
|
||||
# installed.
|
||||
_override_extra_args = ("--no-deps",)
|
||||
pip_install(
|
||||
"Installing dependency overrides",
|
||||
"--force-reinstall",
|
||||
"--no-cache-dir",
|
||||
*_override_extra_args,
|
||||
_torchao_spec,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3704,35 +3704,49 @@ if (-not $NeedLlamaSourceBuild) {
|
|||
$CmakeArgs += '-DCMAKE_EXE_LINKER_FLAGS=/NODEFAULTLIB:LIBCMT'
|
||||
# CUDA flags -- only if GPU available, otherwise explicitly disable
|
||||
if ($HasNvidiaSmi -and $NvccPath) {
|
||||
$CmakeArgs += '-DGGML_CUDA=ON'
|
||||
# Accept a host MSVC newer than nvcc's whitelist; a fresh toolkit
|
||||
# (e.g. CUDA 13.3) otherwise aborts with "#error -- unsupported
|
||||
# Microsoft Visual Studio version!". Mirrors the Linux fix. Via env
|
||||
# (covers the configure probe + build), after Refresh-Environment, idempotent.
|
||||
$nvccAllowFlag = '-allow-unsupported-compiler'
|
||||
if ([string]::IsNullOrEmpty($env:NVCC_PREPEND_FLAGS)) {
|
||||
$env:NVCC_PREPEND_FLAGS = $nvccAllowFlag
|
||||
} elseif ($env:NVCC_PREPEND_FLAGS -notlike "*$nvccAllowFlag*") {
|
||||
$env:NVCC_PREPEND_FLAGS = "$($env:NVCC_PREPEND_FLAGS) $nvccAllowFlag"
|
||||
}
|
||||
substep "NVCC_PREPEND_FLAGS = $env:NVCC_PREPEND_FLAGS"
|
||||
$CmakeArgs += "-DCUDAToolkit_ROOT=$CudaToolkitRoot"
|
||||
$CmakeArgs += "-DCUDA_TOOLKIT_ROOT_DIR=$CudaToolkitRoot"
|
||||
$CmakeArgs += "-DCMAKE_CUDA_COMPILER=$NvccPath"
|
||||
if ($CudaArch) {
|
||||
# Validate nvcc actually supports this architecture
|
||||
if (Test-NvccArchSupport -NvccExe $NvccPath -Arch $CudaArch) {
|
||||
$CmakeArgs += "-DCMAKE_CUDA_ARCHITECTURES=$CudaArch"
|
||||
} else {
|
||||
# GPU arch too new for this toolkit -- fall back to highest supported.
|
||||
# PTX forward-compatibility will JIT-compile for the actual GPU at runtime.
|
||||
$maxArch = Get-NvccMaxArch -NvccExe $NvccPath
|
||||
if ($maxArch) {
|
||||
$CmakeArgs += "-DCMAKE_CUDA_ARCHITECTURES=$maxArch"
|
||||
substep "GPU is sm_$CudaArch but nvcc only supports up to sm_$maxArch" "Yellow"
|
||||
substep "Building with sm_$maxArch (PTX will JIT for your GPU at runtime)" "Yellow"
|
||||
# UNSLOTH_LLAMA_CUDA_ARCHS (e.g. "120" or "89;86") forces the build
|
||||
# arch and wins over detection, matching setup.sh.
|
||||
$CudaArchOverride = if ($env:UNSLOTH_LLAMA_CUDA_ARCHS) { ($env:UNSLOTH_LLAMA_CUDA_ARCHS -replace '\s', '') } else { '' }
|
||||
if ((-not $CudaArch) -and (-not $CudaArchOverride)) {
|
||||
# No detectable compute capability (#5854): -DGGML_CUDA=ON with no
|
||||
# arch builds a PTX-only binary, so build CPU instead. Mirrors the
|
||||
# Linux fix; set UNSLOTH_LLAMA_CUDA_ARCHS=120 to force a CUDA build.
|
||||
substep "could not detect a CUDA compute capability; building CPU llama.cpp instead of a PTX-only binary (set UNSLOTH_LLAMA_CUDA_ARCHS=120 to force a CUDA build)." "Yellow"
|
||||
$CmakeArgs += '-DGGML_CUDA=OFF'
|
||||
} else {
|
||||
$CmakeArgs += '-DGGML_CUDA=ON'
|
||||
# Accept a host MSVC newer than nvcc's whitelist; a fresh toolkit
|
||||
# (e.g. CUDA 13.3) otherwise aborts with "#error -- unsupported
|
||||
# Microsoft Visual Studio version!". Mirrors the Linux fix. Via env
|
||||
# (covers the configure probe + build), after Refresh-Environment, idempotent.
|
||||
$nvccAllowFlag = '-allow-unsupported-compiler'
|
||||
if ([string]::IsNullOrEmpty($env:NVCC_PREPEND_FLAGS)) {
|
||||
$env:NVCC_PREPEND_FLAGS = $nvccAllowFlag
|
||||
} elseif ($env:NVCC_PREPEND_FLAGS -notlike "*$nvccAllowFlag*") {
|
||||
$env:NVCC_PREPEND_FLAGS = "$($env:NVCC_PREPEND_FLAGS) $nvccAllowFlag"
|
||||
}
|
||||
substep "NVCC_PREPEND_FLAGS = $env:NVCC_PREPEND_FLAGS"
|
||||
$CmakeArgs += "-DCUDAToolkit_ROOT=$CudaToolkitRoot"
|
||||
$CmakeArgs += "-DCUDA_TOOLKIT_ROOT_DIR=$CudaToolkitRoot"
|
||||
$CmakeArgs += "-DCMAKE_CUDA_COMPILER=$NvccPath"
|
||||
if ($CudaArchOverride) {
|
||||
# Forced arch wins verbatim (no nvcc validation), matching setup.sh.
|
||||
$CmakeArgs += "-DCMAKE_CUDA_ARCHITECTURES=$CudaArchOverride"
|
||||
} elseif ($CudaArch) {
|
||||
# Validate nvcc actually supports this architecture
|
||||
if (Test-NvccArchSupport -NvccExe $NvccPath -Arch $CudaArch) {
|
||||
$CmakeArgs += "-DCMAKE_CUDA_ARCHITECTURES=$CudaArch"
|
||||
} else {
|
||||
# GPU arch too new for this toolkit -- fall back to highest supported.
|
||||
# PTX forward-compatibility will JIT-compile for the actual GPU at runtime.
|
||||
$maxArch = Get-NvccMaxArch -NvccExe $NvccPath
|
||||
if ($maxArch) {
|
||||
$CmakeArgs += "-DCMAKE_CUDA_ARCHITECTURES=$maxArch"
|
||||
substep "GPU is sm_$CudaArch but nvcc only supports up to sm_$maxArch" "Yellow"
|
||||
substep "Building with sm_$maxArch (PTX will JIT for your GPU at runtime)" "Yellow"
|
||||
}
|
||||
# else: omit flag entirely, let cmake pick defaults
|
||||
}
|
||||
# else: omit flag entirely, let cmake pick defaults
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -155,6 +155,31 @@ _nvcc_meets_llama_minimum() {
|
|||
echo "$_raw"
|
||||
}
|
||||
|
||||
# Echo a ';'-separated CUDA arch list (e.g. "86;120"). Override ($2,
|
||||
# UNSLOTH_LLAMA_CUDA_ARCHS) wins verbatim; else parse+dedupe compute_cap text
|
||||
# ($1). Empty means "no arch detected", so the caller builds CPU instead of a
|
||||
# PTX-only binary that fails on an old driver (#5854).
|
||||
_resolve_cuda_archs() {
|
||||
local _raw_caps=$1
|
||||
local _arch_override=$2
|
||||
if [ -n "$_arch_override" ]; then
|
||||
printf '%s' "$_arch_override"
|
||||
return 0
|
||||
fi
|
||||
local _archs="" _cap _arch
|
||||
while IFS= read -r _cap; do
|
||||
_cap=$(printf '%s' "$_cap" | tr -d '[:space:]')
|
||||
if [[ "$_cap" =~ ^([0-9]+)\.([0-9]+)$ ]]; then
|
||||
_arch="${BASH_REMATCH[1]}${BASH_REMATCH[2]}"
|
||||
case ";$_archs;" in
|
||||
*";$_arch;"*) ;;
|
||||
*) _archs="${_archs:+$_archs;}$_arch" ;;
|
||||
esac
|
||||
fi
|
||||
done <<< "$_raw_caps"
|
||||
printf '%s' "$_archs"
|
||||
}
|
||||
|
||||
# Run a GPU probe under a 10s timeout when `timeout` is available so a wedged
|
||||
# NVIDIA driver cannot hang setup; fall back to a bare call where it is not.
|
||||
_setup_run_smi() {
|
||||
|
|
@ -1517,35 +1542,38 @@ else
|
|||
fi
|
||||
|
||||
if [ "$_CUDA_TOOLKIT_ALLOWED" = true ]; then
|
||||
CMAKE_ARGS="$CMAKE_ARGS -DGGML_CUDA=ON"
|
||||
|
||||
CUDA_ARCHS=""
|
||||
if command -v nvidia-smi &>/dev/null; then
|
||||
_raw_caps=$(_setup_run_smi nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null || true)
|
||||
while IFS= read -r _cap; do
|
||||
_cap=$(echo "$_cap" | tr -d '[:space:]')
|
||||
if [[ "$_cap" =~ ^([0-9]+)\.([0-9]+)$ ]]; then
|
||||
_arch="${BASH_REMATCH[1]}${BASH_REMATCH[2]}"
|
||||
case ";$CUDA_ARCHS;" in
|
||||
*";$_arch;"*) ;;
|
||||
*) CUDA_ARCHS="${CUDA_ARCHS:+$CUDA_ARCHS;}$_arch" ;;
|
||||
esac
|
||||
fi
|
||||
done <<< "$_raw_caps"
|
||||
# Resolve the arch list before committing to a CUDA build;
|
||||
# an empty list means CPU instead of a PTX-only binary (#5854).
|
||||
_raw_caps=""
|
||||
# Resolve nvidia-smi as _setup_has_usable_nvidia_gpu does
|
||||
# (PATH, then /usr/bin); `command -v` alone would miss an
|
||||
# off-PATH binary and wrongly drop a CUDA host to CPU.
|
||||
_smi_bin=""
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
_smi_bin="nvidia-smi"
|
||||
elif [ -x "/usr/bin/nvidia-smi" ]; then
|
||||
_smi_bin="/usr/bin/nvidia-smi"
|
||||
fi
|
||||
if [ -n "$_smi_bin" ]; then
|
||||
_raw_caps=$(_setup_run_smi "$_smi_bin" --query-gpu=compute_cap --format=csv,noheader 2>/dev/null || true)
|
||||
fi
|
||||
CUDA_ARCHS="$(_resolve_cuda_archs "$_raw_caps" "${UNSLOTH_LLAMA_CUDA_ARCHS:-}")"
|
||||
|
||||
if [ -n "$CUDA_ARCHS" ]; then
|
||||
CMAKE_ARGS="$CMAKE_ARGS -DCMAKE_CUDA_ARCHITECTURES=${CUDA_ARCHS}"
|
||||
CMAKE_ARGS="$CMAKE_ARGS -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=${CUDA_ARCHS}"
|
||||
CMAKE_ARGS="$CMAKE_ARGS -DCMAKE_CUDA_FLAGS=--threads=0"
|
||||
_BUILD_DESC="building (CUDA, sm_${CUDA_ARCHS//;/+sm_})"
|
||||
|
||||
# Allow a host gcc/clang newer than nvcc's whitelist (else a fresh
|
||||
# toolkit aborts with "unsupported GNU version"); via env to avoid word-splitting.
|
||||
export NVCC_PREPEND_FLAGS="${NVCC_PREPEND_FLAGS:+$NVCC_PREPEND_FLAGS }-allow-unsupported-compiler"
|
||||
else
|
||||
_BUILD_DESC="building (CUDA)"
|
||||
# No detectable arch: build CPU (CMAKE_ARGS has no
|
||||
# -DGGML_CUDA=ON yet, so clearing GPU_BACKEND yields CPU).
|
||||
substep "could not detect a CUDA compute capability; building CPU llama.cpp instead of a PTX-only binary (set UNSLOTH_LLAMA_CUDA_ARCHS, e.g. \"120\", to force a CUDA build)." "$C_WARN"
|
||||
GPU_BACKEND=""
|
||||
_BUILD_DESC="building (CPU, CUDA arch undetectable)"
|
||||
fi
|
||||
|
||||
CMAKE_ARGS="$CMAKE_ARGS -DCMAKE_CUDA_FLAGS=--threads=0"
|
||||
|
||||
# Allow a host gcc/clang newer than nvcc's whitelist (else a fresh
|
||||
# toolkit aborts with "unsupported GNU version"); via env to avoid word-splitting.
|
||||
export NVCC_PREPEND_FLAGS="${NVCC_PREPEND_FLAGS:+$NVCC_PREPEND_FLAGS }-allow-unsupported-compiler"
|
||||
fi
|
||||
fi
|
||||
elif [ "$GPU_BACKEND" = "rocm" ]; then
|
||||
|
|
@ -1794,6 +1822,7 @@ else
|
|||
printf " ${C_DIM}%-15s${C_OK}%s${C_RST}\n" "launch" "unsloth studio -p 8888"
|
||||
fi
|
||||
printf " ${C_DIM}%-15s%s${C_RST}\n" "" "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
printf " ${C_DIM}%-15s%s${C_RST}\n" "" "(add --secure to allow HTTPS)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
|
|
|
|||
209
studio/src-tauri/Cargo.lock
generated
209
studio/src-tauri/Cargo.lock
generated
|
|
@ -294,6 +294,15 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
|
|
@ -518,6 +527,12 @@ dependencies = [
|
|||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
|
|
@ -537,6 +552,12 @@ dependencies = [
|
|||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
|
|
@ -661,6 +682,15 @@ dependencies = [
|
|||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.29.6"
|
||||
|
|
@ -717,6 +747,15 @@ version = "0.0.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
|
|
@ -823,9 +862,20 @@ version = "0.10.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -846,7 +896,7 @@ dependencies = [
|
|||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1061,7 +1111,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1729,11 +1779,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
|||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1806,6 +1856,15 @@ version = "1.10.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
|
|
@ -2274,9 +2333,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libdbus-sys"
|
||||
|
|
@ -2332,9 +2391,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
|
|
@ -2435,9 +2494,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
|
|
@ -2472,7 +2531,7 @@ dependencies = [
|
|||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2902,7 +2961,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3621,9 +3680,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
version = "1.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
|
|
@ -3644,9 +3703,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
|
|
@ -3790,7 +3849,7 @@ dependencies = [
|
|||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3846,7 +3905,7 @@ dependencies = [
|
|||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4078,9 +4137,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
|
|
@ -4111,9 +4170,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.0.4"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
|
||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
|
@ -4210,7 +4269,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
|||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4277,7 +4347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4561,9 +4631,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
|||
|
||||
[[package]]
|
||||
name = "tauri"
|
||||
version = "2.11.1"
|
||||
version = "2.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405"
|
||||
checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -4648,7 +4718,7 @@ dependencies = [
|
|||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"syn 2.0.117",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
|
|
@ -4706,9 +4776,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.0"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809"
|
||||
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
|
|
@ -4724,9 +4794,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8"
|
||||
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
|
|
@ -4742,7 +4812,7 @@ dependencies = [
|
|||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
|
|
@ -4767,9 +4837,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.3"
|
||||
version = "2.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f"
|
||||
checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"glob",
|
||||
|
|
@ -4799,9 +4869,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.0"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c"
|
||||
checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -4981,10 +5051,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5116,9 +5186,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.50.0"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
|
|
@ -5133,9 +5203,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.1"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -5195,13 +5265,28 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
|||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde_core",
|
||||
"serde_spanned 1.0.4",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde_core",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.3"
|
||||
|
|
@ -5222,9 +5307,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.0.1+spec-1.1.0"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
|
@ -5260,25 +5345,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"toml_datetime 1.0.1+spec-1.1.0",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.0.10+spec-1.1.0"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.0.7+spec-1.1.0"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d"
|
||||
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
|
|
@ -5375,7 +5460,7 @@ dependencies = [
|
|||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5403,9 +5488,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
|||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
|
|
@ -5415,7 +5500,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5495,7 +5580,7 @@ dependencies = [
|
|||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.11.0",
|
||||
"simplelog",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
|
|
@ -5509,7 +5594,7 @@ dependencies = [
|
|||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
"windows 0.62.2",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5978,7 +6063,7 @@ version = "0.1.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6650,7 +6735,7 @@ dependencies = [
|
|||
"once_cell",
|
||||
"percent-encoding",
|
||||
"raw-window-handle",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"soup3",
|
||||
"tao-macros",
|
||||
"thiserror 2.0.18",
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ tauri-plugin-process = "2"
|
|||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
log = "0.4"
|
||||
|
|
@ -23,7 +23,7 @@ regex = "1"
|
|||
open = "5"
|
||||
process-wrap = { version = "9", features = ["std"] }
|
||||
fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" }
|
||||
tauri-plugin-opener = "2.5.3"
|
||||
tauri-plugin-opener = "2.5.4"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
|
|
@ -39,7 +39,7 @@ elevated-command = "1.1.2"
|
|||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62.2", features = ["Win32_System_Threading"] }
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Console", "Win32_System_JobObjects", "Win32_System_Threading"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Console", "Win32_System_JobObjects", "Win32_System_Threading"] }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use hmac::{Hmac, Mac};
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
|
|
|||
99
tests/python/test_cpo_processor_text_tokenizer.py
Normal file
99
tests/python/test_cpo_processor_text_tokenizer.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""CPO shares ORPO's row-tokenization replacements (issue #4952).
|
||||
|
||||
CPOTrainer reuses ORPO's tokenize/init code, so the ORPO rewriters must also be
|
||||
registered for cpo_trainer. The rewriters themselves are covered by
|
||||
test_orpo_processor_text_tokenizer.py; here we just check cpo mirrors orpo.
|
||||
Static, CPU-only, no torch.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import os
|
||||
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
RL_PATH = os.path.join(REPO_ROOT, "unsloth", "models", "rl_replacements.py")
|
||||
|
||||
|
||||
def _registrations(source):
|
||||
"""Map each RL_FUNCTIONS[key] target to the appended function names."""
|
||||
out = {}
|
||||
for node in ast.walk(ast.parse(source)):
|
||||
if not isinstance(node, ast.Expr):
|
||||
continue
|
||||
call = node.value
|
||||
if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute)):
|
||||
continue
|
||||
if call.func.attr != "append":
|
||||
continue
|
||||
sub = call.func.value
|
||||
if not (isinstance(sub, ast.Subscript) and isinstance(sub.value, ast.Name)):
|
||||
continue
|
||||
if sub.value.id != "RL_FUNCTIONS":
|
||||
continue
|
||||
key = sub.slice
|
||||
if not (isinstance(key, ast.Constant) and isinstance(key.value, str)):
|
||||
continue
|
||||
arg = call.args[0]
|
||||
if isinstance(arg, ast.Name):
|
||||
out.setdefault(key.value, []).append(arg.id)
|
||||
return out
|
||||
|
||||
|
||||
def test_cpo_registration_matches_orpo():
|
||||
regs = _registrations(open(RL_PATH).read())
|
||||
shared = {"orpo_trainer_text_tokenizer", "orpo_trainer_processor_pad_token"}
|
||||
assert shared <= set(regs.get("orpo_trainer", []))
|
||||
assert shared <= set(regs.get("cpo_trainer", []))
|
||||
|
||||
|
||||
def _load_pad_rewriter():
|
||||
"""Exec orpo_trainer_processor_pad_token (+ _PAD_FALLBACK) without importing unsloth."""
|
||||
tree = ast.parse(open(RL_PATH).read())
|
||||
nodes = []
|
||||
for n in tree.body:
|
||||
if isinstance(n, ast.Assign) and any(
|
||||
getattr(t, "id", None) == "_PAD_FALLBACK" for t in n.targets
|
||||
):
|
||||
nodes.append(n)
|
||||
elif isinstance(n, ast.FunctionDef) and n.name == "orpo_trainer_processor_pad_token":
|
||||
nodes.append(n)
|
||||
import re as _re
|
||||
|
||||
ns = {"re": _re}
|
||||
exec(compile(ast.Module(body = nodes, type_ignores = []), RL_PATH, "exec"), ns)
|
||||
return ns["orpo_trainer_processor_pad_token"]
|
||||
|
||||
|
||||
def test_pad_token_default_routed_through_inner_tokenizer():
|
||||
# TRL 1.x CPO/ORPO __init__ defaults pad_token from eos_token before
|
||||
# tokenizing; on a multimodal processor those live on `.tokenizer`. The
|
||||
# rewrite must route both the default and pad_token_id through the inner
|
||||
# tokenizer so a processor without bare pad_token does not AttributeError.
|
||||
rewrite = _load_pad_rewriter()
|
||||
init_src = (
|
||||
"def __init__(self, model, args, processing_class):\n"
|
||||
" if processing_class.pad_token is None:\n"
|
||||
" processing_class.pad_token = processing_class.eos_token\n"
|
||||
" self.pad_token_id = processing_class.pad_token_id\n"
|
||||
)
|
||||
out = rewrite("__init__", init_src)
|
||||
assert "if processing_class.pad_token is None:" not in out
|
||||
assert "processing_class.pad_token = processing_class.eos_token" not in out
|
||||
assert "_unsloth_proc_tok = getattr(processing_class, 'tokenizer', processing_class)" in out
|
||||
# bare pad_token_id must be routed through the getattr fallback, not left raw
|
||||
assert "= processing_class.pad_token_id\n" not in out
|
||||
ast.parse(out) # rewritten source still compiles
|
||||
|
||||
|
||||
def test_pad_rewrite_noop_without_bare_pad_block():
|
||||
# Older TRL (the pinned <=0.24.0 range) has no bare pad_token block; the
|
||||
# rewrite must only touch pad_token_id and leave everything else intact.
|
||||
rewrite = _load_pad_rewriter()
|
||||
init_src = (
|
||||
"def __init__(self, model, args, processing_class):\n"
|
||||
" self.pad_token_id = processing_class.pad_token_id\n"
|
||||
)
|
||||
out = rewrite("__init__", init_src)
|
||||
assert "_unsloth_proc_tok" not in out
|
||||
assert "= processing_class.pad_token_id\n" not in out # still routed via fallback
|
||||
ast.parse(out)
|
||||
|
|
@ -866,13 +866,11 @@ class TestInstallPythonStackFiltering:
|
|||
|
||||
result_path = ips._filter_requirements(extras, ips.NO_TORCH_SKIP_PACKAGES)
|
||||
filtered = Path(result_path).read_text(encoding = "utf-8").lower()
|
||||
lines = [
|
||||
l.strip() for l in filtered.splitlines() if l.strip() and not l.strip().startswith("#")
|
||||
]
|
||||
|
||||
for pkg in ["torch-stoi", "timm", "openai-whisper", "transformers-cfg"]:
|
||||
lines = [
|
||||
l.strip()
|
||||
for l in filtered.splitlines()
|
||||
if l.strip() and not l.strip().startswith("#")
|
||||
]
|
||||
for pkg in ips.NO_TORCH_SKIP_PACKAGES:
|
||||
assert not any(
|
||||
l.startswith(pkg) for l in lines
|
||||
), f"{pkg} should be removed from extras.txt"
|
||||
|
|
|
|||
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