diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs deleted file mode 100644 index 17d96cd0f5..0000000000 --- a/.git-blame-ignore-revs +++ /dev/null @@ -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 diff --git a/.github/workflows/consolidated-tests-ci.yml b/.github/workflows/consolidated-tests-ci.yml index f7c338d76b..b56a6c2615 100644 --- a/.github/workflows/consolidated-tests-ci.yml +++ b/.github/workflows/consolidated-tests-ci.yml @@ -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 diff --git a/.github/workflows/local-agent-guides-ci.yml b/.github/workflows/local-agent-guides-ci.yml index 150dbc3fde..299ee3f18b 100644 --- a/.github/workflows/local-agent-guides-ci.yml +++ b/.github/workflows/local-agent-guides-ci.yml @@ -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 diff --git a/.github/workflows/mlx-ci.yml b/.github/workflows/mlx-ci.yml index 221e86f235..864630f9f0 100644 --- a/.github/workflows/mlx-ci.yml +++ b/.github/workflows/mlx-ci.yml @@ -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 \ diff --git a/.github/workflows/studio-api-smoke.yml b/.github/workflows/studio-api-smoke.yml index b196805cf7..15efee382e 100644 --- a/.github/workflows/studio-api-smoke.yml +++ b/.github/workflows/studio-api-smoke.yml @@ -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 diff --git a/.github/workflows/studio-backend-ci.yml b/.github/workflows/studio-backend-ci.yml index 6e53a290cf..ea60252cf6 100644 --- a/.github/workflows/studio-backend-ci.yml +++ b/.github/workflows/studio-backend-ci.yml @@ -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 diff --git a/.github/workflows/studio-inference-smoke.yml b/.github/workflows/studio-inference-smoke.yml index c2c4fa03bf..aebf90380a 100644 --- a/.github/workflows/studio-inference-smoke.yml +++ b/.github/workflows/studio-inference-smoke.yml @@ -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 diff --git a/.github/workflows/studio-mac-api-smoke.yml b/.github/workflows/studio-mac-api-smoke.yml index 412726538c..617ce189dc 100644 --- a/.github/workflows/studio-mac-api-smoke.yml +++ b/.github/workflows/studio-mac-api-smoke.yml @@ -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 diff --git a/.github/workflows/studio-mac-inference-smoke.yml b/.github/workflows/studio-mac-inference-smoke.yml index c794a34acd..d562294d42 100644 --- a/.github/workflows/studio-mac-inference-smoke.yml +++ b/.github/workflows/studio-mac-inference-smoke.yml @@ -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 diff --git a/.github/workflows/studio-mac-install-matrix.yml b/.github/workflows/studio-mac-install-matrix.yml index da944d4b5c..362305cdd4 100644 --- a/.github/workflows/studio-mac-install-matrix.yml +++ b/.github/workflows/studio-mac-install-matrix.yml @@ -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 diff --git a/.github/workflows/studio-mac-ui-smoke.yml b/.github/workflows/studio-mac-ui-smoke.yml index 4f9f94b534..512af54d53 100644 --- a/.github/workflows/studio-mac-ui-smoke.yml +++ b/.github/workflows/studio-mac-ui-smoke.yml @@ -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 diff --git a/.github/workflows/studio-mac-update-smoke.yml b/.github/workflows/studio-mac-update-smoke.yml index f554a16415..d104306c7e 100644 --- a/.github/workflows/studio-mac-update-smoke.yml +++ b/.github/workflows/studio-mac-update-smoke.yml @@ -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 diff --git a/.github/workflows/studio-ui-smoke.yml b/.github/workflows/studio-ui-smoke.yml index dcf9fd26af..297a585430 100644 --- a/.github/workflows/studio-ui-smoke.yml +++ b/.github/workflows/studio-ui-smoke.yml @@ -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 diff --git a/.github/workflows/studio-update-smoke.yml b/.github/workflows/studio-update-smoke.yml index 307bb51972..08a79afacd 100644 --- a/.github/workflows/studio-update-smoke.yml +++ b/.github/workflows/studio-update-smoke.yml @@ -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 diff --git a/.github/workflows/studio-windows-api-smoke.yml b/.github/workflows/studio-windows-api-smoke.yml index 78efe918ac..e9abd2d669 100644 --- a/.github/workflows/studio-windows-api-smoke.yml +++ b/.github/workflows/studio-windows-api-smoke.yml @@ -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; diff --git a/.github/workflows/studio-windows-inference-smoke.yml b/.github/workflows/studio-windows-inference-smoke.yml index a997c89b67..c44c68278d 100644 --- a/.github/workflows/studio-windows-inference-smoke.yml +++ b/.github/workflows/studio-windows-inference-smoke.yml @@ -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' diff --git a/.github/workflows/studio-windows-ui-smoke.yml b/.github/workflows/studio-windows-ui-smoke.yml index 00458d213b..405309916a 100644 --- a/.github/workflows/studio-windows-ui-smoke.yml +++ b/.github/workflows/studio-windows-ui-smoke.yml @@ -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, diff --git a/.github/workflows/studio-windows-update-smoke.yml b/.github/workflows/studio-windows-update-smoke.yml index 1a2a7df493..888b3d70a3 100644 --- a/.github/workflows/studio-windows-update-smoke.yml +++ b/.github/workflows/studio-windows-update-smoke.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bf2a0c8e7c..8dcb9130b3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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: diff --git a/install.ps1 b/install.ps1 index 765f33b1ff..745c7369ea 100644 --- a/install.ps1 +++ b/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 "" } } diff --git a/install.sh b/install.sh index 7a2e18e374..62cc2920e6 100755 --- a/install.sh +++ b/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 diff --git a/pyproject.toml b/pyproject.toml index 83d65bc1a3..dd957766b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/scripts/scan_packages_baseline.json b/scripts/scan_packages_baseline.json index 67952c24f1..f953f4d206 100644 --- a/scripts/scan_packages_baseline.json +++ b/scripts/scan_packages_baseline.json @@ -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, '', 'exec'),\nExec: L934: eval(compile(func_code_str, '', '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", diff --git a/scripts/verify_import_hoist.py b/scripts/verify_import_hoist.py index b8cb0573fe..b4c908b0cb 100644 --- a/scripts/verify_import_hoist.py +++ b/scripts/verify_import_hoist.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", diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py index 2b9517692f..4dca4db768 100644 --- a/studio/backend/core/inference/inference.py +++ b/studio/backend/core/inference/inference.py @@ -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"]*>", "", 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']}" + formatted += f" {content_to_text(conversation[i + 1]['content'])}" 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: " diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index eedda112f4..83b56c0091 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -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 ) 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 += "" 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 += "" @@ -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 += "" diff --git a/studio/backend/core/inference/message_content.py b/studio/backend/core/inference/message_content.py new file mode 100644 index 0000000000..b7c499a087 --- /dev/null +++ b/studio/backend/core/inference/message_content.py @@ -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) diff --git a/studio/backend/core/inference/tool_call_parser.py b/studio/backend/core/inference/tool_call_parser.py index 8d5d45269e..ca3d1e4cbc 100644 --- a/studio/backend/core/inference/tool_call_parser.py +++ b/studio/backend/core/inference/tool_call_parser.py @@ -7,25 +7,31 @@ Tolerates missing closing tags in either ``{json}`` or ``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".*?", re.DOTALL), - re.compile(r".*?", re.DOTALL), -] -_TOOL_ALL_PATS = _TOOL_CLOSED_PATS + [ - re.compile(r".*$", re.DOTALL), - re.compile(r".*$", 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>", "\s*\{") -_TC_FUNC_START_RE = re.compile(r"\s*") -_TC_END_TAG_RE = re.compile(r"") -_TC_FUNC_CLOSE_RE = re.compile(r"\s*\s*$") -# [\w-] so hyphenated MCP param names (issue-number) aren't dropped. -_TC_PARAM_START_RE = re.compile(r"\s*") -_TC_PARAM_CLOSE_RE = re.compile(r"\s*\s*$") -_PARAM_CLOSE_TAG = "" -_FUNC_CLOSE_TAG = "" - - -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 ```` tags: - ``{"name":"web_search","arguments":{"query":"..."}}`` - - XML-style function blocks: - ``v`` - - ``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: {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: v... -- closing tags optional; - # 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 tag rather than - # the end of the response, so a complete call followed by - # trailing prose is still accepted (matching the JSON-style - # path, which already tolerates trailing text). - # rfind picks the last , so a literal - # 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 - # 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) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 6960310018..a5c193ff39 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -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, ) diff --git a/studio/backend/core/rag/embeddings.py b/studio/backend/core/rag/embeddings.py index 4e76e4fcaa..4c8d690302 100644 --- a/studio/backend/core/rag/embeddings.py +++ b/studio/backend/core/rag/embeddings.py @@ -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() diff --git a/studio/backend/core/tool_healing.py b/studio/backend/core/tool_healing.py index 973520d5cd..fe26d48c7f 100644 --- a/studio/backend/core/tool_healing.py +++ b/studio/backend/core/tool_healing.py @@ -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".*?", re.DOTALL), + re.compile(r"<\|tool_call>.*?", re.DOTALL), + re.compile(r""), re.compile(r".*?", re.DOTALL), ] _TOOL_ALL_PATS = _TOOL_CLOSED_PATS + [ re.compile(r".*$", re.DOTALL), + re.compile(r"<\|tool_call>.*$", re.DOTALL), re.compile(r".*$", re.DOTALL), ] # Pre-compiled patterns for tool-call XML parsing. _TC_JSON_START_RE = re.compile(r"\s*\{") +_TC_GEMMA_START_RE = re.compile(r"<\|tool_call>call:([\w-]+)\s*\{") _TC_FUNC_START_RE = re.compile(r"\s*") _TC_END_TAG_RE = re.compile(r"") +_TC_GEMMA_END_TAG_RE = re.compile(r"") _TC_FUNC_CLOSE_RE = re.compile(r"\s*\s*$") _TC_PARAM_START_RE = re.compile(r"\s*") _TC_PARAM_CLOSE_RE = re.compile(r"\s*\s*$") +_GEMMA_QUOTE = '<|"|>' +_PARAM_CLOSE_TAG = "" +_FUNC_CLOSE_TAG = "" +# 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: {"name":"web_search","arguments":{"query":"..."}} + <|tool_call>call:web_search{query:"..."} ... - Closing tags (, , ) are all - optional since models frequently omit them. """ - tool_calls = [] - - # Pattern 1: JSON inside 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 value + # is that parameter's data, not its own call; skip it (same guard the + # XML-style parser below applies to nested = 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 value - # All closing tags optional; models frequently omit them. if not tool_calls: - # Step 1: Find positions and extract bodies. Use only - # or the next - # can appear in code values); trim a trailing 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 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 + 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 - # 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 . 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 + 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, diff --git a/studio/backend/core/training/training.py b/studio/backend/core/training/training.py index 34bb4331a7..290b4090ec 100644 --- a/studio/backend/core/training/training.py +++ b/studio/backend/core/training/training.py @@ -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 = { diff --git a/studio/backend/main.py b/studio/backend/main.py index 6da6cf1a7d..d9bc74f883 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -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 = ["*"], diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index b8432f588c..26825a472e 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -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): diff --git a/studio/backend/requirements/extras-no-deps.txt b/studio/backend/requirements/extras-no-deps.txt index 23c61baa44..5830a47789 100644 --- a/studio/backend/requirements/extras-no-deps.txt +++ b/studio/backend/requirements/extras-no-deps.txt @@ -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" diff --git a/studio/backend/requirements/extras.txt b/studio/backend/requirements/extras.txt index 40737b0876..1baf2b6f2d 100644 --- a/studio/backend/requirements/extras.txt +++ b/studio/backend/requirements/extras.txt @@ -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 diff --git a/studio/backend/requirements/no-torch-runtime.txt b/studio/backend/requirements/no-torch-runtime.txt index b0157cfea0..9796fc3a50 100644 --- a/studio/backend/requirements/no-torch-runtime.txt +++ b/studio/backend/requirements/no-torch-runtime.txt @@ -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 diff --git a/studio/backend/requirements/single-env/constraints.txt b/studio/backend/requirements/single-env/constraints.txt index aad4c38664..0ed2bf8b26 100644 --- a/studio/backend/requirements/single-env/constraints.txt +++ b/studio/backend/requirements/single-env/constraints.txt @@ -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 diff --git a/studio/backend/requirements/single-env/overrides-darwin-arm64.txt b/studio/backend/requirements/single-env/overrides-darwin-arm64.txt index 2cd03d8b78..56b948644f 100644 --- a/studio/backend/requirements/single-env/overrides-darwin-arm64.txt +++ b/studio/backend/requirements/single-env/overrides-darwin-arm64.txt @@ -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 diff --git a/studio/backend/requirements/studio.txt b/studio/backend/requirements/studio.txt index 96fef60471..1b7f7a668c 100644 --- a/studio/backend/requirements/studio.txt +++ b/studio/backend/requirements/studio.txt @@ -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 diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 39240b2c27..a8a75d1cf5 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -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 {json} and - # .... 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 {json}, + # ..., and Gemma native <|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 "" not in chat_template and "" 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-]+)>.*?(?:|\Z)" + r"|<\|tool_call>.*?(?:|\Z)" r"|" + r"|" r"|\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 diff --git a/studio/backend/run.py b/studio/backend/run.py index 709efc2098..bb57db9bcb 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -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 diff --git a/studio/backend/tests/test_bypass_permissions.py b/studio/backend/tests/test_bypass_permissions.py index 563f146816..d92509a5fe 100644 --- a/studio/backend/tests/test_bypass_permissions.py +++ b/studio/backend/tests/test_bypass_permissions.py @@ -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 ─────────────────── diff --git a/studio/backend/tests/test_exec_utf8.py b/studio/backend/tests/test_exec_utf8.py new file mode 100644 index 0000000000..90b78754ed --- /dev/null +++ b/studio/backend/tests/test_exec_utf8.py @@ -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) diff --git a/studio/backend/tests/test_gemma_tool_parse_edge_cases.py b/studio/backend/tests/test_gemma_tool_parse_edge_cases.py new file mode 100644 index 0000000000..8df8d37a52 --- /dev/null +++ b/studio/backend/tests/test_gemma_tool_parse_edge_cases.py @@ -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}" + ) + 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"}') + 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}" + ) + 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 = ( + '{"name":"python","arguments":{"code":' + '"x = 1 # <|tool_call>call:terminal{command:ls}"}}' + ) + 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} and <|tool_call>call:b{y:2}" + 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} then " + '{"name":"read","arguments":{"path":"a"}}' + ) + 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({"name":"terminal","arguments":{"command":"ls"}})' + '<|"|>}' + ) + 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}}" + 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]}") + 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]}' + ) + 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}]}" + ) + 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]]}") + assert _args(calls[0]) == {"cells": [["a", "b"], ["c", "d"]]} + + +def test_gemma_marker_inside_xml_parameter_is_not_a_second_call(): + # An XML-style call whose 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 = ( + "" + "x = 1 # <|tool_call>call:terminal{command:ls}" + "" + ) + 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 = ( + "" + 'run({"name":"terminal","arguments":{"command":"ls"}})' + "" + ) + calls = parse_tool_calls_from_text(content) + assert [c["function"]["name"] for c in calls] == ["python"], calls diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index d69eccc54d..e9941d9e62 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -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] diff --git a/studio/backend/tests/test_llama_cpp_tool_loop.py b/studio/backend/tests/test_llama_cpp_tool_loop.py index 687829980b..56e028bd5a 100644 --- a/studio/backend/tests/test_llama_cpp_tool_loop.py +++ b/studio/backend/tests/test_llama_cpp_tool_loop.py @@ -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"] + == "I am thinking. Still thinking.Final answer." + ) + + +def test_consumed_tool_final_pass_emits_latest_reasoning_summary(monkeypatch): + tool_stream = [ + _sse({"reasoning_content": "Need a render."}), + _sse( + { + "content": '{"name":"render_html","arguments":{"code":"ok"}}' + } + ), + _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.""" diff --git a/studio/backend/tests/test_llama_route_timeouts.py b/studio/backend/tests/test_llama_route_timeouts.py index 5aee6198ba..24866bd03e 100644 --- a/studio/backend/tests/test_llama_route_timeouts.py +++ b/studio/backend/tests/test_llama_route_timeouts.py @@ -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) diff --git a/studio/backend/tests/test_mcp_servers.py b/studio/backend/tests/test_mcp_servers.py index 90b1ade03c..12239e7113 100644 --- a/studio/backend/tests/test_mcp_servers.py +++ b/studio/backend/tests/test_mcp_servers.py @@ -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"} 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 after") == "before after" + assert strip_tool_call_markup("before 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<|"|>}' + ) + 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 = ( + "" + + _json.dumps({"name": "python", "arguments": {"code": "print('<|\"|>')"}}) + + "" + ) + 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).""" diff --git a/studio/backend/tests/test_message_content.py b/studio/backend/tests/test_message_content.py new file mode 100644 index 0000000000..6da3682141 --- /dev/null +++ b/studio/backend/tests/test_message_content.py @@ -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) diff --git a/studio/backend/tests/test_mlx_repair.py b/studio/backend/tests/test_mlx_repair.py index 7e60c9c3b9..bb8dfce049 100644 --- a/studio/backend/tests/test_mlx_repair.py +++ b/studio/backend/tests/test_mlx_repair.py @@ -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. diff --git a/studio/backend/tests/test_openai_tool_passthrough.py b/studio/backend/tests/test_openai_tool_passthrough.py index 2586076321..aaef9e4dcc 100644 --- a/studio/backend/tests/test_openai_tool_passthrough.py +++ b/studio/backend/tests/test_openai_tool_passthrough.py @@ -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 "plan" + yield "planvis" + yield "planvisible" + 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("" 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 "planvisible" + 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 "leakedvisible" + 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("" 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": 'planvisible <|tool_call>call:terminal{command:"ls"}', + } + 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 "planvisible" + 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()) diff --git a/studio/backend/tests/test_responses_tool_passthrough.py b/studio/backend/tests/test_responses_tool_passthrough.py index 0bea355668..4147746b54 100644 --- a/studio/backend/tests/test_responses_tool_passthrough.py +++ b/studio/backend/tests/test_responses_tool_passthrough.py @@ -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 "" not in body["output"][1]["content"][0]["text"] assert "" not in body["output"][1]["content"][0]["text"] + def test_unclosed_think_block_extracts_as_reasoning(self): + reasoning, visible = _extract_responses_reasoning( + "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 x tags" + def test_reasoning_capable_gguf_parses_think_tags_by_default(self, monkeypatch): + body = self._run_with_message( + monkeypatch, + {"content": "plananswer"}, + 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": "leakedanswer"}, + 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": "x tags"}}]}, + {"choices": [{"delta": {"content": "plananswer"}}]}, {"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 x 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 x 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 x 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": "plan"}}]}, {"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": "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 = [ diff --git a/studio/backend/tests/test_safetensors_capability_advertise.py b/studio/backend/tests/test_safetensors_capability_advertise.py index 13cb6bbd46..671af93708 100644 --- a/studio/backend/tests/test_safetensors_capability_advertise.py +++ b/studio/backend/tests/test_safetensors_capability_advertise.py @@ -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{...}, 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}{%- 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 ``\n...``. Faithful slice so the # classifier never silently regresses for this family. diff --git a/studio/backend/tests/test_safetensors_tool_loop.py b/studio/backend/tests/test_safetensors_tool_loop.py index d61c0b389c..3f2d49f0dd 100644 --- a/studio/backend/tests/test_safetensors_tool_loop.py +++ b/studio/backend/tests/test_safetensors_tool_loop.py @@ -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:"."}' + 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<|"|>}' + 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<|"|>}' + 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"}' + 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}"}' + 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}" + 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 = "print('hi')" result = parse_tool_calls_from_text(text) @@ -121,6 +167,7 @@ class TestParser: def test_has_tool_signal(self): assert has_tool_signal("blah x") + assert has_tool_signal("blah <|tool_call>call:terminal") assert has_tool_signal("hi ...") assert not has_tool_signal("hello world") @@ -139,6 +186,8 @@ class TestParser: def test_strip_markup_closed(self): text = "before {} after" assert strip_tool_markup(text) == "before after" + text = 'before <|tool_call>call:terminal{command:"ls"} after' + assert strip_tool_markup(text) == "before after" def test_strip_markup_unclosed_final(self): text = "before {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 {"name":"web_search"' diff --git a/studio/backend/tests/test_secure_tunnel_gate.py b/studio/backend/tests/test_secure_tunnel_gate.py index d254121c14..bf9836e8f3 100644 --- a/studio/backend/tests/test_secure_tunnel_gate.py +++ b/studio/backend/tests/test_secure_tunnel_gate.py @@ -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 diff --git a/studio/backend/tests/test_ssm_runtime.py b/studio/backend/tests/test_ssm_runtime.py index 2f6bae9b79..bb0caa2887 100644 --- a/studio/backend/tests/test_ssm_runtime.py +++ b/studio/backend/tests/test_ssm_runtime.py @@ -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(): diff --git a/studio/backend/tests/test_tool_call_parser_strict.py b/studio/backend/tests/test_tool_call_parser_strict.py index 8ff41342d7..931d8a705d 100644 --- a/studio/backend/tests/test_tool_call_parser_strict.py +++ b/studio/backend/tests/test_tool_call_parser_strict.py @@ -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:"."}' " 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"}' + 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<|"|>}' + 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}" + ) + 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 = "cats" diff --git a/studio/backend/tests/test_tool_xml_strip.py b/studio/backend/tests/test_tool_xml_strip.py index 2ba3310fbe..c2dc1fe8db 100644 --- a/studio/backend/tests/test_tool_xml_strip.py +++ b/studio/backend/tests/test_tool_xml_strip.py @@ -125,6 +125,14 @@ def test_strips_orphan_closing_tag(): # Mid-string intentionally preserved (see preserve test). +def test_strips_gemma_native_orphan_closing_tag(): + cleaned = _TOOL_XML_RE.sub("", "Tool call drained.Visible tail.") + + assert "" not in cleaned + assert "Tool call drained." in cleaned + assert "Visible tail." in cleaned + + # ── Tail-only (PR #5735 follow-up) ─────────────────── diff --git a/studio/backend/tests/test_torchao_select.py b/studio/backend/tests/test_torchao_select.py index 393a74daaf..a99eb4c45c 100644 --- a/studio/backend/tests/test_torchao_select.py +++ b/studio/backend/tests/test_torchao_select.py @@ -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 diff --git a/studio/backend/tests/test_vision_cache.py b/studio/backend/tests/test_vision_cache.py index 192bec53c8..4349687c54 100644 --- a/studio/backend/tests/test_vision_cache.py +++ b/studio/backend/tests/test_vision_cache.py @@ -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() diff --git a/studio/backend/utils/host_policy.py b/studio/backend/utils/host_policy.py index bd9ebd68ba..f506eadc03 100644 --- a/studio/backend/utils/host_policy.py +++ b/studio/backend/utils/host_policy.py @@ -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. diff --git a/studio/backend/utils/mlx_repair.py b/studio/backend/utils/mlx_repair.py index 06d0289166..7e980deb82 100644 --- a/studio/backend/utils/mlx_repair.py +++ b/studio/backend/utils/mlx_repair.py @@ -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; " diff --git a/studio/backend/utils/models/checkpoints.py b/studio/backend/utils/models/checkpoints.py index 5a992926ec..63f599d0df 100644 --- a/studio/backend/utils/models/checkpoints.py +++ b/studio/backend/utils/models/checkpoints.py @@ -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], diff --git a/studio/frontend/package-lock.json b/studio/frontend/package-lock.json index a521552d79..6202d3ce22 100644 --- a/studio/frontend/package-lock.json +++ b/studio/frontend/package-lock.json @@ -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", diff --git a/studio/frontend/package.json b/studio/frontend/package.json index c49b62ab50..0956c710f5 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -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, diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index b7e7bc01d2..77ba5788db 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -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 ( diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 734860138a..71956ff9db 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -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() { )} - + {/* 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", )} /> - + + {/* Update affordance — shows only when a newer version is available. */} + {showUpdateCard && ( + + + + )} @@ -1381,11 +1454,16 @@ export function AppSidebar() { Unsloth {/* settings cog (replaces the up/down chevron) */} - + 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({ )} {onBrowseHub ? ( - + + + + + Search all models + ) : null} @@ -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 ? ( -
+
diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index a58cdd94d7..5e8fc9cb25 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -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), diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index 589fae5fe9..7112a7877c 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -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/); } diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index daad2c4524..a288a60fb6 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -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" > 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({ Run settings Chat inference settings -
{settingsContent}
+
+ {settingsContent} +
); @@ -1783,6 +1782,7 @@ export function ChatSettingsPanel({ return (