diff --git a/.github/scripts/hf-download-with-retry.sh b/.github/scripts/hf-download-with-retry.sh new file mode 100755 index 0000000000..c5ee013c80 --- /dev/null +++ b/.github/scripts/hf-download-with-retry.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# +# Download a single file from a Hugging Face repo with a stall-retry +# watchdog. Used by the Studio CI workflows so a hung hf-xet transfer +# kills + retries instead of silently consuming the job's timeout. +# +# Usage: hf-download-with-retry.sh REPO FILE LOCAL_DIR +# +# Why this exists +# --------------- +# huggingface_hub 1.15+ deprecated `hf_transfer` and routes every +# transfer through the `hf-xet` binary package. In CI we observed +# `hf download` on a 3 GB GGUF (gemma-4-E2B-it-UD-Q4_K_XL) progress +# to ~46% via Xet, then go completely silent for the remainder of +# the 30-min job timeout -- no progress bytes, no error, no exit. +# A sibling 940 MB mmproj on the same step downloaded in ~21s +# moments earlier, so the hang is per-file inside hf-xet rather +# than a network outage. The Xet env-vars below put hf-xet into +# its highest-throughput mode and force a 500 s client-read +# timeout; the watchdog loop ensures a stall does not eat the +# whole job: if the hf process has not exited after STALL_S +# seconds (default 180 = 3 min), we SIGTERM, then SIGKILL, then +# start a fresh attempt. Retries are unbounded -- the enclosing +# GitHub Actions job's `timeout-minutes` is the real bound. +# +# See https://huggingface.co/docs/huggingface_hub/package_reference/environment_variables +# for the HF_XET_* documentation, and npm/cli#7308's pattern (silent +# CI hang with no error) for prior art on this class of failure. + +set -uo pipefail + +REPO="${1:?usage: hf-download-with-retry.sh REPO FILE [LOCAL_DIR]}" +FILE="${2:?usage: hf-download-with-retry.sh REPO FILE [LOCAL_DIR]}" +# LOCAL_DIR is optional. If empty, hf falls back to HF_HUB_CACHE +# (~/.cache/huggingface/hub) which is the desired path for callers +# that populate HF_HOME for a downstream Studio model load. +LOCAL_DIR="${3:-}" + +# Stall threshold per attempt, in seconds. Override with +# HF_DOWNLOAD_STALL_SECONDS in the workflow env if 3 min is too tight +# for a specific runner / file. The script keeps retrying past this +# until the job timeout fires. +STALL_S="${HF_DOWNLOAD_STALL_SECONDS:-180}" + +# hf-xet tuning. HF_HUB_ENABLE_HF_TRANSFER is deliberately NOT set -- +# it is a no-op on huggingface_hub>=1.15 and only emits a deprecation +# FutureWarning. The five HF_XET_* knobs below mirror the settings +# Daniel asked for: max bandwidth + 64 parallel range gets, no chunk +# cache (download-once usage pattern), parallel disk writes (SSD/NVMe +# runners), and a generous 500 s read timeout so individual chunk +# requests fail loudly instead of stalling forever. +export HF_XET_HIGH_PERFORMANCE=1 +export HF_XET_CHUNK_CACHE_SIZE_BYTES=0 +export HF_XET_NUM_CONCURRENT_RANGE_GETS=64 +export HF_XET_RECONSTRUCT_WRITE_SEQUENTIALLY=0 +export HF_XET_CLIENT_READ_TIMEOUT=500 + +if [ -n "$LOCAL_DIR" ]; then + mkdir -p "$LOCAL_DIR" +fi + +attempt=1 +while : ; do + log="$(mktemp -t hf-download.XXXXXX)" + echo "[hf-download] $FILE attempt $attempt (stall threshold ${STALL_S}s, log=$log)" + + if [ -n "$LOCAL_DIR" ]; then + hf download "$REPO" "$FILE" --local-dir "$LOCAL_DIR" > "$log" 2>&1 & + else + hf download "$REPO" "$FILE" > "$log" 2>&1 & + fi + pid=$! + + elapsed=0 + while kill -0 "$pid" 2>/dev/null && [ "$elapsed" -lt "$STALL_S" ]; do + sleep 5 + elapsed=$((elapsed + 5)) + done + + if kill -0 "$pid" 2>/dev/null; then + echo "[hf-download] $FILE attempt $attempt exceeded ${STALL_S}s -- killing PID $pid and retrying" + kill -TERM "$pid" 2>/dev/null || true + sleep 2 + kill -KILL "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + echo "[hf-download] $FILE attempt $attempt log tail (last 40 lines):" + tail -40 "$log" || true + attempt=$((attempt + 1)) + continue + fi + + if wait "$pid"; then + rc=0 + else + rc=$? + fi + + if [ "$rc" -eq 0 ]; then + echo "[hf-download] $FILE attempt $attempt succeeded" + tail -20 "$log" || true + exit 0 + fi + + echo "[hf-download] $FILE attempt $attempt failed (exit $rc) -- retrying" + tail -40 "$log" || true + attempt=$((attempt + 1)) +done diff --git a/.github/workflows/consolidated-tests-ci.yml b/.github/workflows/consolidated-tests-ci.yml index abceb91567..6b008d4bb1 100644 --- a/.github/workflows/consolidated-tests-ci.yml +++ b/.github/workflows/consolidated-tests-ci.yml @@ -206,7 +206,8 @@ jobs: 'numpy<3' pytest==9.0.3 pytest-asyncio httpx \ protobuf sentencepiece triton \ psutil packaging tqdm safetensors datasets \ - 'peft>=0.18,<0.20' 'accelerate>=0.34,<2' + 'peft>=0.18,<0.20' 'accelerate>=0.34,<2' \ + ipython # torchvision: unsloth_zoo.vision_utils imports it at module scope. pip install --index-url https://download.pytorch.org/whl/cpu \ 'torch>=2.4,<2.11' 'torchvision<0.26' @@ -304,6 +305,17 @@ jobs: run: | python -m pytest -v --tb=short tests/test_import_fixes_drift.py + - name: public-api surface drift detectors (9 tests, HARD GATE) + # Companion to test_import_fixes_drift.py: that file catches + # third-party drift; this one catches drift in unsloth's OWN + # public surface (FastLanguageModel / FastVisionModel / + # FastModel + their classmethods + is_bf16_supported). A + # rename here would silently break the unslothai/notebooks tree + # one PR cycle later -- this gate catches it BEFORE the + # breakage reaches users. + run: | + python -m pytest -v --tb=short tests/test_public_api_surface.py + - name: unsloth Bucket-A — CPU tests not in Repo tests (CPU) # 16 tests across 5 files. They live inside tests/saving/ and # tests/utils/, both of which Repo tests (CPU) excludes via --ignore @@ -875,14 +887,23 @@ jobs: import _zoo_aggressive_cuda_spoof as _spoof _spoof.apply() - # Hermetic cache dir + force compile path BEFORE importing - # unsloth_zoo.compiler (its globals capture env at module load). + # Hermetic cache dir + force compile path. The compiler's + # globals (UNSLOTH_COMPILE_LOCATION, UNSLOTH_COMPILE_USE_TEMP) + # are captured at module load; an earlier conftest `import + # unsloth` may have already imported unsloth_zoo.compiler with + # the default "unsloth_compiled_cache" path. Mutate the live + # module globals after import so this shim is robust to that + # ordering. Otherwise the compiler silently writes to the + # default cache and the per-model file assertion fails. _CACHE = pathlib.Path(tempfile.mkdtemp(prefix="unsloth_cache_")) os.environ["UNSLOTH_COMPILE_LOCATION"] = str(_CACHE) os.environ["UNSLOTH_COMPILE_OVERWRITE"] = "1" os.environ.pop("UNSLOTH_COMPILE_DISABLE", None) import pytest + import unsloth_zoo.compiler as _zoo_compiler + _zoo_compiler.UNSLOTH_COMPILE_LOCATION = str(_CACHE) + _zoo_compiler.UNSLOTH_COMPILE_USE_TEMP = False from unsloth_zoo.compiler import unsloth_compile_transformers @@ -941,6 +962,12 @@ jobs: # Category E: undefined name in emitted file. "perceiver": "name 'AbstractPreprocessor' is not defined", "sam3_lite_text": "name 'Sam3LiteTextLayerScaledResidual' is not defined", + # Category F: compile exceeds 60s budget on the runner. + # First seen on transformers >=5,<6; each represents a slow + # or recursive source-rewriter path the zoo can address. + "beit": "TimeoutError: compile exceeds per-model budget", + "sam": "TimeoutError: compile exceeds per-model budget", + "sam_hq": "TimeoutError: compile exceeds per-model budget", } @@ -956,40 +983,59 @@ jobs: skipped -> no `modeling_.py` file (expected for some umbrella packages like `auto`, `deprecated`) known -> in KNOWN_BROKEN_COMPILE; tracked for follow-up. - Any uncaught failure fails the cell.""" + Any uncaught failure fails the cell. + + Per-model SIGALRM cap so one infinite-looping model_type + cannot wedge the whole sweep + nuke the job timeout + (observed on transformers >=5,<6 -- 30+ min hang before + this guard landed).""" import importlib as _il + import signal ok = 0 skipped = [] known = [] new_failures = [] - for model_type in _all_model_types(): - modeling_path = f"transformers.models.{model_type}.modeling_{model_type}" - try: - _il.import_module(modeling_path) - except (ModuleNotFoundError, ImportError): - skipped.append((model_type, "no modeling file")) - continue - try: - unsloth_compile_transformers( - model_type=model_type, fast_lora_forwards=False, - ) - except Exception as e: - msg = f"{type(e).__name__}: {str(e)[:200]}" + models = _all_model_types() + def _on_timeout(signum, frame): + raise TimeoutError("compile exceeded per-model budget") + prev_handler = signal.signal(signal.SIGALRM, _on_timeout) + try: + for i, model_type in enumerate(models): + if i % 25 == 0: + print(f" sweep progress: {i}/{len(models)} -> {model_type}", flush=True) + modeling_path = f"transformers.models.{model_type}.modeling_{model_type}" + try: + _il.import_module(modeling_path) + except (ModuleNotFoundError, ImportError): + skipped.append((model_type, "no modeling file")) + continue + signal.alarm(60) + try: + unsloth_compile_transformers( + model_type=model_type, fast_lora_forwards=False, + ) + except Exception as e: + signal.alarm(0) + msg = f"{type(e).__name__}: {str(e)[:200]}" + if model_type in KNOWN_BROKEN_COMPILE: + known.append((model_type, msg)) + else: + new_failures.append((model_type, msg)) + continue + signal.alarm(0) if model_type in KNOWN_BROKEN_COMPILE: - known.append((model_type, msg)) - else: - new_failures.append((model_type, msg)) - continue - if model_type in KNOWN_BROKEN_COMPILE: - # Came back green unexpectedly -- that's GOOD news, - # the bug was fixed. Surface it so we can drop the - # entry from KNOWN_BROKEN_COMPILE. - print( - f" UNEXPECTED-OK {model_type}: was in " - "KNOWN_BROKEN_COMPILE, now compiles cleanly. " - "Drop the entry." - ) - ok += 1 + # Came back green unexpectedly -- that's GOOD news, + # the bug was fixed. Surface it so we can drop the + # entry from KNOWN_BROKEN_COMPILE. + print( + f" UNEXPECTED-OK {model_type}: was in " + "KNOWN_BROKEN_COMPILE, now compiles cleanly. " + "Drop the entry." + ) + ok += 1 + finally: + signal.alarm(0) + signal.signal(signal.SIGALRM, prev_handler) print(f"\nCompile sweep: ok={ok} skipped={len(skipped)} " f"known-broken={len(known)} new-failures={len(new_failures)}") for m, r in known: @@ -1020,24 +1066,34 @@ jobs: """Spot-check on the three production-relevant families that the compile_every sweep also covers; this case verifies the emitted cache file has the model-specific RMSNorm class - attribute, not just that the file parses + imports.""" + attribute, not just that the file parses + imports. + + ``unsloth_compile_transformers`` is not idempotent in- + process: calling it twice on the same modeling module + after rewriting class attributes corrupts the inspect + source/line cache and the second emitted file is malformed + Python. The sweep above already produced a valid cache + file for every non-KNOWN_BROKEN model_type, so just verify + that artefact here. Trigger a compile only when running + this test in isolation (no sweep preceded).""" import importlib as _il try: - _il.import_module( + modeling = _il.import_module( f"transformers.models.{model_type}.modeling_{model_type}" ) except ModuleNotFoundError: pytest.skip( f"transformers build lacks model_type={model_type}" ) - unsloth_compile_transformers( - model_type=model_type, fast_lora_forwards=False, - ) - modeling = _il.import_module( - f"transformers.models.{model_type}.modeling_{model_type}" - ) - assert getattr(modeling, "__UNSLOTH_PATCHED__", False) is True combined = _CACHE / f"unsloth_compiled_module_{model_type}.py" + if not combined.exists(): + unsloth_compile_transformers( + model_type=model_type, fast_lora_forwards=False, + ) + modeling = _il.import_module( + f"transformers.models.{model_type}.modeling_{model_type}" + ) + assert getattr(modeling, "__UNSLOTH_PATCHED__", False) is True _verify_file(combined, must_expose=[rms_class]) diff --git a/.github/workflows/mlx-ci.yml b/.github/workflows/mlx-ci.yml index 8cd95bd30a..75940832a0 100644 --- a/.github/workflows/mlx-ci.yml +++ b/.github/workflows/mlx-ci.yml @@ -302,15 +302,10 @@ jobs: "$LLAMA_QUANT" --help >/dev/null && echo " llama-quantize loads OK" mkdir -p /tmp/ggufs - python -c " - from huggingface_hub import hf_hub_download - p = hf_hub_download( - 'unsloth/gemma-3-270m-it-GGUF', - 'gemma-3-270m-it-Q4_K_M.gguf', - local_dir = '/tmp/ggufs', - ) - print('downloaded:', p) - " + bash .github/scripts/hf-download-with-retry.sh \ + 'unsloth/gemma-3-270m-it-GGUF' \ + 'gemma-3-270m-it-Q4_K_M.gguf' \ + /tmp/ggufs PORT=18080 echo "=== starting llama-server on 127.0.0.1:$PORT ===" diff --git a/.github/workflows/notebooks-ci.yml b/.github/workflows/notebooks-ci.yml index 0881c5ef3a..673b2f3cc5 100644 --- a/.github/workflows/notebooks-ci.yml +++ b/.github/workflows/notebooks-ci.yml @@ -200,7 +200,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - with: { path: unsloth } + path: unsloth - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: unslothai/notebooks @@ -246,7 +246,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - with: { path: unsloth } + path: unsloth - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: unslothai/notebooks @@ -352,7 +352,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - with: { path: unsloth } + path: unsloth - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: unslothai/notebooks diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 235fde5253..a1e7b2efa6 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -137,8 +137,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27 @@ -1066,8 +1064,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/studio-api-smoke.yml b/.github/workflows/studio-api-smoke.yml index 29f056eca4..53514e2ce1 100644 --- a/.github/workflows/studio-api-smoke.yml +++ b/.github/workflows/studio-api-smoke.yml @@ -63,8 +63,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -87,10 +85,9 @@ jobs: env: HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | - python -m pip install --upgrade huggingface_hub hf_transfer + python -m pip install --upgrade huggingface_hub mkdir -p hf-cache - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$GGUF_FILE" + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" - name: Save HF_HOME for ${{ env.GGUF_REPO }} if: always() && steps.prime-hf.outcome == 'success' diff --git a/.github/workflows/studio-frontend-ci.yml b/.github/workflows/studio-frontend-ci.yml index a93cdb8661..1270a57ef6 100644 --- a/.github/workflows/studio-frontend-ci.yml +++ b/.github/workflows/studio-frontend-ci.yml @@ -15,6 +15,8 @@ on: pull_request: paths: - 'studio/frontend/**' + - 'scripts/check_frontend_dep_removal.py' + - 'tests/studio/test_frontend_dep_removal.py' - '.github/workflows/studio-frontend-ci.yml' push: branches: [main, pip] @@ -57,8 +59,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json # Run the structural lockfile scan BEFORE npm ci. A compromised # tarball runs its `prepare` / `postinstall` during `npm ci`, @@ -86,6 +86,26 @@ jobs: exit 1 fi + # Catch the common foot-gun: a dep dropped from package.json that is + # still imported somewhere. The script walks the lockfile dep graph + # from the new top-level deps and only counts top-level node_modules + # paths as valid resolution targets for bare src/ imports. + # + # actions/checkout uses fetch-depth: 1 by default, so the base branch + # is not available locally. Fetch the single base commit with an + # explicit refspec so origin/ is reliably created (a bare + # `git fetch origin ` only updates FETCH_HEAD in some configs). + - name: Dependency removal safety check + if: github.event_name == 'pull_request' + working-directory: ${{ github.workspace }} + run: | + git fetch --no-tags --depth=1 origin \ + "${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}" + python3 scripts/check_frontend_dep_removal.py \ + --base "origin/${{ github.base_ref }}" \ + --enumerate-dead + python3 tests/studio/test_frontend_dep_removal.py + - name: Typecheck run: npm run typecheck diff --git a/.github/workflows/studio-inference-smoke.yml b/.github/workflows/studio-inference-smoke.yml index ea14e4f5d5..775363e73c 100644 --- a/.github/workflows/studio-inference-smoke.yml +++ b/.github/workflows/studio-inference-smoke.yml @@ -79,8 +79,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -101,10 +99,9 @@ jobs: env: HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | - python -m pip install --upgrade huggingface_hub hf_transfer + python -m pip install --upgrade huggingface_hub mkdir -p hf-cache - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$GGUF_FILE" + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" - name: Save HF_HOME for ${{ env.GGUF_REPO }} if: always() && steps.prime-hf.outcome == 'success' @@ -329,8 +326,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -351,10 +346,9 @@ jobs: env: HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | - python -m pip install --upgrade huggingface_hub hf_transfer + python -m pip install --upgrade huggingface_hub mkdir -p gguf-cache - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$GGUF_FILE" --local-dir gguf-cache + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache - name: Save GGUF model file if: always() && steps.download-gguf.outcome == 'success' @@ -648,8 +642,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -670,12 +662,10 @@ jobs: env: HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | - python -m pip install --upgrade huggingface_hub hf_transfer + python -m pip install --upgrade huggingface_hub mkdir -p hf-cache - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$GGUF_FILE" - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$MMPROJ_FILE" + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$MMPROJ_FILE" - name: Save HF_HOME for ${{ env.GGUF_REPO }} (model + mmproj) if: always() && steps.prime-hf.outcome == 'success' diff --git a/.github/workflows/studio-mac-api-smoke.yml b/.github/workflows/studio-mac-api-smoke.yml index aa7a616413..b4e274155e 100644 --- a/.github/workflows/studio-mac-api-smoke.yml +++ b/.github/workflows/studio-mac-api-smoke.yml @@ -50,8 +50,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -72,10 +70,9 @@ jobs: env: HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | - python -m pip install --upgrade huggingface_hub hf_transfer + python -m pip install --upgrade huggingface_hub mkdir -p hf-cache - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$GGUF_FILE" + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" - name: Save HF_HOME for ${{ env.GGUF_REPO }} if: always() && steps.prime-hf.outcome == 'success' diff --git a/.github/workflows/studio-mac-inference-smoke.yml b/.github/workflows/studio-mac-inference-smoke.yml index 4e8456a297..2d6864e0cb 100644 --- a/.github/workflows/studio-mac-inference-smoke.yml +++ b/.github/workflows/studio-mac-inference-smoke.yml @@ -73,8 +73,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -95,10 +93,9 @@ jobs: env: HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | - python -m pip install --upgrade huggingface_hub hf_transfer + python -m pip install --upgrade huggingface_hub mkdir -p hf-cache - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$GGUF_FILE" + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" # Save partial caches on cancel/timeout -- hf download resumes by # content hash. `outcome != skipped` keeps cache-hit a no-op. @@ -325,8 +322,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -347,10 +342,9 @@ jobs: env: HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | - python -m pip install --upgrade huggingface_hub hf_transfer + python -m pip install --upgrade huggingface_hub mkdir -p gguf-cache - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$GGUF_FILE" --local-dir gguf-cache + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache # Save partial caches on cancel; next run resumes via content hash. - name: Save GGUF model file @@ -692,8 +686,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -710,23 +702,32 @@ jobs: continue-on-error: true with: path: gguf-cache - key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-${{ env.MMPROJ_FILE }}-v1 + key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-${{ env.MMPROJ_FILE }}-v2 - - name: Download GGUF + mmproj if cache miss + - name: Verify cache contains BOTH gguf + mmproj + id: verify-cache + if: steps.cache-gguf.outputs.cache-hit == 'true' + run: | + if [[ -f "gguf-cache/$GGUF_FILE" && -f "gguf-cache/$MMPROJ_FILE" ]]; then + echo "ok=true" >> "$GITHUB_OUTPUT" + else + echo "Partial cache hit -- forcing re-download." + echo "ok=false" >> "$GITHUB_OUTPUT" + fi + + - name: Download GGUF + mmproj if cache miss or partial id: download-gguf - if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success' + if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.verify-cache.outputs.ok != 'true' # Authenticated + parallel: shared macos-14 NAT egress stalls # multi-GB anonymous downloads. env: HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | - python -m pip install --upgrade huggingface_hub hf_transfer + python -m pip install --upgrade huggingface_hub mkdir -p gguf-cache - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$GGUF_FILE" --local-dir gguf-cache & + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache & MODEL_PID=$! - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$MMPROJ_FILE" --local-dir gguf-cache & + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$MMPROJ_FILE" gguf-cache & MMPROJ_PID=$! wait "$MODEL_PID" wait "$MMPROJ_PID" @@ -734,13 +735,15 @@ jobs: ls -lh "gguf-cache/$GGUF_FILE" "gguf-cache/$MMPROJ_FILE" # Save partial caches on cancel. hashFiles guard avoids a hard - # save failure when the download step exits with no files. + # save failure when the download step exits with no files. The + # additional mmproj-presence check stops a partial save from + # poisoning the cache for the next run. - name: Save GGUF + mmproj files - if: always() && steps.download-gguf.outcome != 'skipped' && hashFiles('gguf-cache/**/*.gguf') != '' + if: always() && steps.download-gguf.outcome != 'skipped' && hashFiles('gguf-cache/**/*.gguf') != '' && hashFiles(format('gguf-cache/{0}', env.MMPROJ_FILE)) != '' uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: gguf-cache - key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-${{ env.MMPROJ_FILE }}-v1 + key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-${{ env.MMPROJ_FILE }}-v2 - name: Install Studio (--local, --no-torch) env: diff --git a/.github/workflows/studio-mac-ui-smoke.yml b/.github/workflows/studio-mac-ui-smoke.yml index 28a9fc6d1d..510c3543d2 100644 --- a/.github/workflows/studio-mac-ui-smoke.yml +++ b/.github/workflows/studio-mac-ui-smoke.yml @@ -50,8 +50,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -72,10 +70,9 @@ jobs: env: HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | - python -m pip install --upgrade huggingface_hub hf_transfer + python -m pip install --upgrade huggingface_hub mkdir -p hf-cache - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$GGUF_FILE" + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" - name: Save HF_HOME for ${{ env.GGUF_REPO }} if: always() && steps.prime-hf.outcome == 'success' diff --git a/.github/workflows/studio-mac-update-smoke.yml b/.github/workflows/studio-mac-update-smoke.yml index dd2333251a..07d26b9ab3 100644 --- a/.github/workflows/studio-mac-update-smoke.yml +++ b/.github/workflows/studio-mac-update-smoke.yml @@ -52,8 +52,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/studio-tauri-smoke.yml b/.github/workflows/studio-tauri-smoke.yml index 159d5dbbe6..1156c264ae 100644 --- a/.github/workflows/studio-tauri-smoke.yml +++ b/.github/workflows/studio-tauri-smoke.yml @@ -53,8 +53,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27 diff --git a/.github/workflows/studio-ui-smoke.yml b/.github/workflows/studio-ui-smoke.yml index 1f3a5a8594..455fe4b7e1 100644 --- a/.github/workflows/studio-ui-smoke.yml +++ b/.github/workflows/studio-ui-smoke.yml @@ -64,8 +64,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -86,10 +84,9 @@ jobs: env: HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | - python -m pip install --upgrade huggingface_hub hf_transfer + python -m pip install --upgrade huggingface_hub mkdir -p hf-cache - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$GGUF_FILE" + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" - name: Save HF_HOME for ${{ env.GGUF_REPO }} if: always() && steps.prime-hf.outcome == 'success' @@ -232,12 +229,55 @@ jobs: kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true sleep 2 + # IME + multilingual paste regression (issue #5318 / PR #5327). + # Third Studio on its own port so a hang here cannot poison the + # earlier UI tests. No GGUF -- the bug surface is the composer. + - name: Reset auth + boot Studio for IME / i18n tests (port 18896) + run: | + unsloth studio reset-password + mkdir -p logs + UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18896 \ + > logs/studio_ime.log 2>&1 & + echo "STUDIO_IME_PID=$!" >> "$GITHUB_ENV" + + - name: Wait for /api/health on 18896 + run: | + for i in $(seq 1 180); do + if curl -fs "http://127.0.0.1:18896/api/health" > /tmp/health3.json; then + jq -e '.status == "healthy"' /tmp/health3.json && break + fi + sleep 1 + done + jq -e '.status == "healthy"' /tmp/health3.json + + - name: Pass bootstrap pw for IME / i18n test + # IME smoke does the change-password against the bootstrap that + # Studio's frontend injects into the page, so it only needs the + # NEW password. + run: | + NEW="CIIme-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')" + echo "::add-mask::$NEW" + echo "STUDIO_IME_NEW_PW=$NEW" >> "$GITHUB_ENV" + + - name: Drive IME + multilingual paste regression with Playwright + env: + BASE_URL: http://127.0.0.1:18896 + STUDIO_NEW_PW: ${{ env.STUDIO_IME_NEW_PW }} + PW_ART_DIR: logs/playwright_ime + STUDIO_UI_STRICT: '1' + run: | + mkdir -p logs/playwright_ime + python tests/studio/playwright_chat_ime_i18n.py + + - name: Stop third Studio + if: always() + run: | + kill "${STUDIO_IME_PID}" 2>/dev/null || true + sleep 2 + - name: Upload Playwright artifacts - # Always upload (not just failure) so a green run's screenshots - # are reviewable in the Actions UI -- catches "passed but the - # UI is silently broken" regressions that would be invisible - # otherwise. Both Studio's logs (chat + extra) and BOTH - # Playwright artifact dirs are bundled. + # Always upload so a green run's screenshots stay reviewable -- + # catches "passed but the UI is silently broken" regressions. if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -245,7 +285,9 @@ jobs: path: | logs/studio.log logs/studio_extra.log + logs/studio_ime.log logs/install.log logs/playwright logs/playwright_extra + logs/playwright_ime retention-days: 7 diff --git a/.github/workflows/studio-update-smoke.yml b/.github/workflows/studio-update-smoke.yml index 624001142a..1c353e933a 100644 --- a/.github/workflows/studio-update-smoke.yml +++ b/.github/workflows/studio-update-smoke.yml @@ -52,8 +52,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/studio-windows-api-smoke.yml b/.github/workflows/studio-windows-api-smoke.yml index 86a07b41e5..1d12ea6f90 100644 --- a/.github/workflows/studio-windows-api-smoke.yml +++ b/.github/workflows/studio-windows-api-smoke.yml @@ -58,8 +58,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -79,10 +77,9 @@ jobs: env: HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | - python -m pip install --upgrade huggingface_hub hf_transfer + python -m pip install --upgrade huggingface_hub mkdir -p hf-cache - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$GGUF_FILE" + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" - name: Save HF_HOME for ${{ env.GGUF_REPO }} if: always() && steps.prime-hf.outcome == 'success' diff --git a/.github/workflows/studio-windows-inference-smoke.yml b/.github/workflows/studio-windows-inference-smoke.yml index bc13ec8199..01bf4127a7 100644 --- a/.github/workflows/studio-windows-inference-smoke.yml +++ b/.github/workflows/studio-windows-inference-smoke.yml @@ -68,8 +68,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -101,10 +99,9 @@ jobs: env: HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | - python -m pip install --upgrade huggingface_hub hf_transfer + python -m pip install --upgrade huggingface_hub mkdir -p hf-cache - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$GGUF_FILE" + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" - name: Save HF_HOME cache for ${{ env.GGUF_REPO }} # Only write a fresh cache entry when we actually rebuilt the @@ -345,9 +342,13 @@ jobs: - name: Stop Studio if: always() - run: | - kill "${STUDIO_PID}" 2>/dev/null || true - sleep 2 + # Run as cmd so we are not running through the Git Bash shell; + # Git Bash on windows-latest has been observed to exit 143 + # (SIGTERM) from any inline kill/sleep block, masking a green + # test run. The runner reclaims the Studio child process at + # job end either way, so just emit a marker and exit 0. + shell: cmd + run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) - name: Upload logs if: always() @@ -396,8 +397,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -420,10 +419,9 @@ jobs: env: HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | - python -m pip install --upgrade huggingface_hub hf_transfer + python -m pip install --upgrade huggingface_hub mkdir -p gguf-cache - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$GGUF_FILE" --local-dir gguf-cache + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache - name: Save GGUF model cache if: always() && steps.download-gguf.outcome == 'success' @@ -762,9 +760,13 @@ jobs: - name: Stop Studio if: always() - run: | - kill "${STUDIO_PID}" 2>/dev/null || true - sleep 2 + # Run as cmd so we are not running through the Git Bash shell; + # Git Bash on windows-latest has been observed to exit 143 + # (SIGTERM) from any inline kill/sleep block, masking a green + # test run. The runner reclaims the Studio child process at + # job end either way, so just emit a marker and exit 0. + shell: cmd + run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) - name: Upload logs if: always() @@ -806,8 +808,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -833,12 +833,10 @@ jobs: env: HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | - python -m pip install --upgrade huggingface_hub hf_transfer + python -m pip install --upgrade huggingface_hub mkdir -p hf-cache - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$GGUF_FILE" - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$MMPROJ_FILE" + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$MMPROJ_FILE" - name: Save HF_HOME cache for ${{ env.GGUF_REPO }} (model + mmproj) if: always() && steps.prime-hf.outcome == 'success' @@ -1150,9 +1148,13 @@ jobs: - name: Stop Studio if: always() - run: | - kill "${STUDIO_PID}" 2>/dev/null || true - sleep 2 + # Run as cmd so we are not running through the Git Bash shell; + # Git Bash on windows-latest has been observed to exit 143 + # (SIGTERM) from any inline kill/sleep block, masking a green + # test run. The runner reclaims the Studio child process at + # job end either way, so just emit a marker and exit 0. + shell: cmd + run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) - name: Upload logs if: always() diff --git a/.github/workflows/studio-windows-ui-smoke.yml b/.github/workflows/studio-windows-ui-smoke.yml index 90fce0558b..e5ab9f8ab7 100644 --- a/.github/workflows/studio-windows-ui-smoke.yml +++ b/.github/workflows/studio-windows-ui-smoke.yml @@ -63,8 +63,13 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json + # No `cache: 'npm'`. setup-node's npm cache restore silently + # aborts the entire job on Windows runners when the npm cache + # path (`C:\npm\cache` per `npm config get cache`) doesn't yet + # exist on a fresh runner -- the step exits without an error + # message and every following step gets skipped. See + # npm/cli#7308. The frontend `npm ci` is fast enough without + # the cache that the reliability gain is worth the ~30s. - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -88,10 +93,9 @@ jobs: env: HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | - python -m pip install --upgrade huggingface_hub hf_transfer + python -m pip install --upgrade huggingface_hub mkdir -p hf-cache - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$GGUF_FILE" + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" - name: Save HF_HOME for ${{ env.GGUF_REPO }} if: always() && steps.prime-hf.outcome == 'success' diff --git a/.github/workflows/studio-windows-update-smoke.yml b/.github/workflows/studio-windows-update-smoke.yml index 0303bc746d..157874d404 100644 --- a/.github/workflows/studio-windows-update-smoke.yml +++ b/.github/workflows/studio-windows-update-smoke.yml @@ -64,8 +64,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/version-compat-ci.yml b/.github/workflows/version-compat-ci.yml index 1ebea81066..599b53df1d 100644 --- a/.github/workflows/version-compat-ci.yml +++ b/.github/workflows/version-compat-ci.yml @@ -127,6 +127,7 @@ jobs: run: | PYTHONPATH=. python -m pytest \ tests/version_compat/test_peft_pinned_symbols.py \ + tests/version_compat/test_unsloth_zoo_save_merged_pinned_symbols.py \ -v --tb=short st-pinned-symbols: @@ -214,7 +215,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - with: { path: unsloth } + path: unsloth - name: Clone unsloth-zoo @ main run: | # github.com occasionally 500s on the git fetch; retry so a diff --git a/.github/workflows/wheel-smoke.yml b/.github/workflows/wheel-smoke.yml index 464a8e324a..3de3c33ca2 100644 --- a/.github/workflows/wheel-smoke.yml +++ b/.github/workflows/wheel-smoke.yml @@ -48,8 +48,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/pyproject.toml b/pyproject.toml index c66cb870eb..81cf5ac215 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ triton = [ ] huggingfacenotorch = [ + "unsloth_zoo>=2026.5.2", "wheel>=0.42.0", "packaging", "numpy", @@ -1017,7 +1018,44 @@ intelgputorch290 = [ intel-gpu-torch290 = [ "unsloth[intelgputorch290]" ] -intelgputorch210 = [ +intelgputorch271 = [ + "unsloth_zoo[intelgpu]", + "unsloth[huggingfacenotorch]", + + "pytorch_triton_xpu @ https://download.pytorch.org/whl/pytorch_triton_xpu-3.3.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=663ce21364096b268c6687f26f22862cb1001cae0c4ec9f98a0998415f99e2b0 ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", + "pytorch_triton_xpu @ https://download.pytorch.org/whl/pytorch_triton_xpu-3.3.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=dd92cc17000bad19f213b6a877d7f10cd71341b703cd188513ce9fff8d42e3dd ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", + "pytorch_triton_xpu @ https://download.pytorch.org/whl/pytorch_triton_xpu-3.3.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=aa5c3ec21a89e967d1dfe61e3d5b1c1ae9620c871ed804771d3378d6a44066f2 ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", + "pytorch_triton_xpu @ https://download.pytorch.org/whl/pytorch_triton_xpu-3.3.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=d1c6f522e11112a311b1a61ba7b40b43ad8305675fa29153017ccb1ad0b6816d ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", + "pytorch_triton_xpu @ https://download.pytorch.org/whl/pytorch_triton_xpu-3.3.1-cp310-cp310-win_amd64.whl#sha256=a5c16dcf449a9cb62bc3788f7ec45782bb3ead6edc2637a12b60ef0f8f45dc55 ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "pytorch_triton_xpu @ https://download.pytorch.org/whl/pytorch_triton_xpu-3.3.1-cp311-cp311-win_amd64.whl#sha256=bc2d76ffa4ceed5b38ae34b52dbff643442e1a44d52ca72d7cb520ca1950e9ae ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "pytorch_triton_xpu @ https://download.pytorch.org/whl/pytorch_triton_xpu-3.3.1-cp312-cp312-win_amd64.whl#sha256=b09ca59ce52d6d27b1510df783cde222b703a71857a6fa953f1f155f9f50811a ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "pytorch_triton_xpu @ https://download.pytorch.org/whl/pytorch_triton_xpu-3.3.1-cp313-cp313-win_amd64.whl#sha256=1260c4a4bad426b6cd3c8f3e1a21835381c6f217bf434bcb55fedec08a206dea ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + + "torch @ https://download.pytorch.org/whl/xpu/torch-2.7.1%2Bxpu-cp310-cp310-linux_x86_64.whl#sha256=231c3fbd88a75d94de5ccbbb7f4f9a96cb3c58b3d891c2a1b469d38df95f9be6 ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.7.1%2Bxpu-cp311-cp311-linux_x86_64.whl#sha256=78edcc27709dd819fc820f5eb9421bd10d3f3dcb14adb25ee60766c76f0e67f3 ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.7.1%2Bxpu-cp312-cp312-linux_x86_64.whl#sha256=b443df40bc9cb7d648a9f8f9ed1d5c3a1203e561ebd0a61dd55fb8a58833d5ec ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.7.1%2Bxpu-cp313-cp313-linux_x86_64.whl#sha256=412b58ffcceebea399c9a1bcdb22896aa10385c2650a8c4f8a677fb11c49b448 ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.7.1%2Bxpu-cp310-cp310-win_amd64.whl#sha256=2591228dc2cb73c78daf24277c4449ba9474f94cd31938147249269fe89d05d6 ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.7.1%2Bxpu-cp311-cp311-win_amd64.whl#sha256=1aacb86e9a9684ffc8bde3db14b251d00df7019a9a434ec99a59076a2696325d ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.7.1%2Bxpu-cp312-cp312-win_amd64.whl#sha256=9b65dc8562521b60d77aa653132bc03a19da0291318fcf919faa3f03080d8f7e ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.7.1%2Bxpu-cp313-cp313-win_amd64.whl#sha256=cd3669fee311bc3ee5501d696bf989226a6f2bf957d120a04881a07af05526d6 ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + + "bitsandbytes @ https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_main/bitsandbytes-1.33.7.preview-py3-none-manylinux_2_24_x86_64.whl ; ('linux' in sys_platform) and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "bitsandbytes @ https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_main/bitsandbytes-1.33.7.preview-py3-none-win_amd64.whl ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.22.1%2Bxpu-cp310-cp310-manylinux_2_28_x86_64.whl#sha256=f8cdf6889c02b3166679eef661b68757ea7e99c314432c3d41dac3d2ed4a59d4 ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.22.1%2Bxpu-cp311-cp311-manylinux_2_28_x86_64.whl#sha256=f7d15b65d52809745992e0001c25034f33ac01f2dff5248614e07b5d009a59b7 ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.22.1%2Bxpu-cp312-cp312-manylinux_2_28_x86_64.whl#sha256=1ff1f98d70846352c7f56833bedab1a055ead27b11c120b8c719063ee0383554 ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.22.1%2Bxpu-cp313-cp313-manylinux_2_28_x86_64.whl#sha256=f46945344ea911a70309231eaaf3b80c96f6646ce5515dc89aa94f94144e310e ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.22.1%2Bxpu-cp310-cp310-win_amd64.whl#sha256=ecae9a02de769e2070d37388116beb407c3f0d60b8e65c1da1423f4eafee361a ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.22.1%2Bxpu-cp311-cp311-win_amd64.whl#sha256=2914e62782431bebd6ad9a3b98a2b7311e448e84a7534bb7f35874b9279a17de ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.22.1%2Bxpu-cp312-cp312-win_amd64.whl#sha256=5b462c156f4e2097e1e53649d3f298ce352fa4c5d1e6addd360375b10ebd6c67 ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.22.1%2Bxpu-cp313-cp313-win_amd64.whl#sha256=fa87b3677cd1af67ce423004283c1bde80e3571f391182a3e89b485e18e3c70f ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", +] +intel-gpu-torch271 = [ + "unsloth[intelgputorch271]" +] +intelgputorch291 = [ "unsloth_zoo[intelgpu]", "unsloth[huggingfacenotorch]", @@ -1030,6 +1068,43 @@ intelgputorch210 = [ "pytorch_triton_xpu @ https://download.pytorch.org/whl/pytorch_triton_xpu-3.5.0-cp312-cp312-win_amd64.whl#sha256=97337a47425f1963a723475bd61037460e84ba01db4f87a1d662c3718ff6c47e ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", "pytorch_triton_xpu @ https://download.pytorch.org/whl/pytorch_triton_xpu-3.5.0-cp313-cp313-win_amd64.whl#sha256=2caf8138695f6abb023ecd02031a2611ba1bf8fff2f19802567cb2fadefe9e87 ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.9.1%2Bxpu-cp310-cp310-linux_x86_64.whl#sha256=fb7895c744132d6a8e56ce8434ae1d8355c9bda4e9f58832744ff742d6268eaf ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.9.1%2Bxpu-cp311-cp311-linux_x86_64.whl#sha256=da2604a9114a28de71ce654819424d20a246adf644d191ae160837df9731b79e ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.9.1%2Bxpu-cp312-cp312-linux_x86_64.whl#sha256=d5968d78d81c1d01efc1b3bf83d7da3d83161dcc3a9fcf91f500591db1c6c75d ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.9.1%2Bxpu-cp313-cp313-linux_x86_64.whl#sha256=b56d6b0d65863f370527e971dbfa046a5dd2a1f61cc95071db26c764f36e4dce ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.9.1%2Bxpu-cp310-cp310-win_amd64.whl#sha256=2f318fb6a4bf1101cc17f35a5371f7c1768b41fceed03628397834e85b3edfdd ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.9.1%2Bxpu-cp311-cp311-win_amd64.whl#sha256=c9cedc3fb099366b2e6c563df6578e323564b1b5d40ac27be73c674755343a1d ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.9.1%2Bxpu-cp312-cp312-win_amd64.whl#sha256=bee9623254d0f95a1ca115dbd17e9a9d966fdb8ae123e2ada4a9eb2fb8d38db8 ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.9.1%2Bxpu-cp313-cp313-win_amd64.whl#sha256=cd5c857da52a63c121561b30b0979e69ade70b575fd74e389787bc7c1ee2ac11 ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + + "bitsandbytes @ https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_main/bitsandbytes-1.33.7.preview-py3-none-manylinux_2_24_x86_64.whl ; ('linux' in sys_platform) and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "bitsandbytes @ https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_main/bitsandbytes-1.33.7.preview-py3-none-win_amd64.whl ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.24.1%2Bxpu-cp310-cp310-manylinux_2_28_x86_64.whl#sha256=cc5272da2cb4554edf059eedd6d1f5ef2859033b0fb79d5dcb8e99a0697f3325 ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.24.1%2Bxpu-cp311-cp311-manylinux_2_28_x86_64.whl#sha256=3c80d6a068c32fc4ebddb27953e03a0141bd0f10ca8730417cbc0e0748158285 ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.24.1%2Bxpu-cp312-cp312-manylinux_2_28_x86_64.whl#sha256=8cf640a867cf270b3fda7a10002c29d3fc2ad6dfbd76404a8cdd820489adb04c ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.24.1%2Bxpu-cp313-cp313-manylinux_2_28_x86_64.whl#sha256=d9c59ee5ae3d0560f02401c8dfd8054d50813a8dbb5d33a8777de7d02f6fcb7b ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.24.1%2Bxpu-cp310-cp310-win_amd64.whl#sha256=843ea7fcd8f5a22ebbc20d2d61d9eec7593821a0372eb8cabb73953d12ef6acf ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.24.1%2Bxpu-cp311-cp311-win_amd64.whl#sha256=e5ff8a31d3c700f8dbac59697c8e32298a43ec059609ebc6ea7bab3eff6384e1 ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.24.1%2Bxpu-cp312-cp312-win_amd64.whl#sha256=8bae6d4c042f8d20818da4a5aa9109c6fbd6ec11bc422be152ce8adf9a7095bf ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.24.1%2Bxpu-cp313-cp313-win_amd64.whl#sha256=47059e290fc2a41ba78666ffcde102c436abf7ff8a34d200268b48c4fa0f9c45 ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", +] +intel-gpu-torch291 = [ + "unsloth[intelgputorch291]" +] +intelgputorch210 = [ + "unsloth_zoo[intelgpu]", + "unsloth[huggingfacenotorch]", + + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.6.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.6.0-cp310-cp310-win_amd64.whl ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.6.0-cp311-cp311-win_amd64.whl ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.6.0-cp312-cp312-win_amd64.whl ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.6.0-cp313-cp313-win_amd64.whl ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.10.0%2Bxpu-cp310-cp310-linux_x86_64.whl#sha256=abb1d1ec1ac672bac0ff35420c965f2df0c636ef9d94e2a830e34578489d0a57 ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", "torch @ https://download.pytorch.org/whl/xpu/torch-2.10.0%2Bxpu-cp311-cp311-linux_x86_64.whl#sha256=71ad2f82da0f41eaec159f39fc85854e27c2391efa91b373e550648a6f4aaad3 ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", "torch @ https://download.pytorch.org/whl/xpu/torch-2.10.0%2Bxpu-cp312-cp312-linux_x86_64.whl#sha256=b473571d478912f92881cc13f15fa18f8463fb0fb8a068c96ed47a7d45a4da0a ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", @@ -1054,6 +1129,80 @@ intelgputorch210 = [ intel-gpu-torch210 = [ "unsloth[intelgputorch210]" ] +intelgputorch2110 = [ + "unsloth_zoo[intelgpu]", + "unsloth[huggingfacenotorch]", + + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=2a1841138750f708ec017becbf8d357526f3fa350deee6553be5735ad66160a3 ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=e85378f1fc1ea002271de2a35475b75008fa554b86ef9d3bc55be9c513a63b51 ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=a6663ebe43e3c0d560ff774708632d7a75208ee64a291c1724ed5c16a92d1c72 ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=08c8d43b2831faf9d6799480df2b45dde58102257aebd810d07a2ce18cd4e5df ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.0-cp310-cp310-win_amd64.whl#sha256=90fb8f767950a4ffca627faa7f86d9c697237ea4352d7e23505c5c9ed8e72216 ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.0-cp311-cp311-win_amd64.whl#sha256=aa7de82f4265089e74f25a2701b7532e5c47d74224d877b61da1d66156e3f0c1 ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.0-cp312-cp312-win_amd64.whl#sha256=5ba3a31c6e1b259ad2d924e1b50f72a78c6ebd7eb4f364473bbf93e144734e80 ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.0-cp313-cp313-win_amd64.whl#sha256=e8b4caba9b2399ea4c7f9a2777042564dea5d6f9e586a2dcb015a4ce20f000f7 ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + + "torch @ https://download.pytorch.org/whl/xpu/torch-2.11.0%2Bxpu-cp310-cp310-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.11.0%2Bxpu-cp311-cp311-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.11.0%2Bxpu-cp312-cp312-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.11.0%2Bxpu-cp313-cp313-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.11.0%2Bxpu-cp310-cp310-win_amd64.whl ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.11.0%2Bxpu-cp311-cp311-win_amd64.whl ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.11.0%2Bxpu-cp312-cp312-win_amd64.whl ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.11.0%2Bxpu-cp313-cp313-win_amd64.whl ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + + "bitsandbytes @ https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_main/bitsandbytes-1.33.7.preview-py3-none-manylinux_2_24_x86_64.whl ; ('linux' in sys_platform) and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "bitsandbytes @ https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_main/bitsandbytes-1.33.7.preview-py3-none-win_amd64.whl ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.26.0%2Bxpu-cp310-cp310-manylinux_2_28_x86_64.whl#sha256=6e634354b752b7366e8ad16b84f3e7e5863776a7ab448bbabae4fd36668dee7a ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.26.0%2Bxpu-cp311-cp311-manylinux_2_28_x86_64.whl#sha256=293169899f562ce473a58836dd024f0b1e72a347400278287ab393d1b04991e4 ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.26.0%2Bxpu-cp312-cp312-manylinux_2_28_x86_64.whl#sha256=e204d14be6f0f84d5f0e6e9213556e80326c3ab682cac108bcbef340bf45297b ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.26.0%2Bxpu-cp313-cp313-manylinux_2_28_x86_64.whl#sha256=f134344006f0989a2d771554b7905fb05bd93d63b195e64626fde3495ec6f287 ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.26.0%2Bxpu-cp310-cp310-win_amd64.whl#sha256=7e52729cb9736c66dc79a7f42de6b31db93b9161d3357fd34cfa33f5fe32b8ea ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.26.0%2Bxpu-cp311-cp311-win_amd64.whl#sha256=83a6130100c6b6750d8aa9fd29e5d0c53b1c85b1153b8ed4139aea54fc1892cc ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.26.0%2Bxpu-cp312-cp312-win_amd64.whl#sha256=03788e0e5a5b85a2f09d11f0263d579fcb0cf5623d8810149be0e37836c2738c ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.26.0%2Bxpu-cp313-cp313-win_amd64.whl#sha256=cb1da1d378ce440f7d1e0ed8cf21bd280d904ab25a55c9453f8377825818df74 ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", +] +intel-gpu-torch2110 = [ + "unsloth[intelgputorch2110]" +] +intelgputorch2120 = [ + "unsloth_zoo[intelgpu]", + "unsloth[huggingfacenotorch]", + + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=844d981cb1b3948085e8cfa62c74de9f100259f6131959aa70be49123b88ae81 ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=a16b1d00e94ad87d62af3512e390348b8656419598004100c56028bf494f086b ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=4e46e71e077cf483404a4c17ce40d71c5f0e13a81459139d4346ca427b1dd455 ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=4fdaed1bafc51d3a2834656a3420a6686a74ea226508765a49bf15d58ff3a930 ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp310-cp310-win_amd64.whl#sha256=2778b46b22e9fa0916398db299a125027a1b2331c1173b3dd2b9e2cab6263a31 ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp311-cp311-win_amd64.whl#sha256=ad5b147d04ee0d40f3d4d32f85f5aa3a3beb6cd5799ca026d3d7f4afa3d9e24f ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp312-cp312-win_amd64.whl#sha256=d9482063af2a308543f23333e32edd738ea87cbb33ade68afda9ae0fd704ccd9 ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp313-cp313-win_amd64.whl#sha256=5d4d67f0deb1e851c01b293e602b8dcddad26ca2be61221cee3dc0e1aa0cdefd ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + + "torch @ https://download.pytorch.org/whl/xpu/torch-2.12.0%2Bxpu-cp310-cp310-linux_x86_64.whl#sha256=e8923cd1fe560472904b1461b745d2f1826bb9c1bc0808225d5f28a450e4d553 ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.12.0%2Bxpu-cp311-cp311-linux_x86_64.whl#sha256=f7c082b2fc9b61def594d30ea57762dc4a8bc7111a9a9593953ed948de242e28 ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.12.0%2Bxpu-cp312-cp312-linux_x86_64.whl#sha256=f59decc04bec27862ed0197554a52370dbcba3e6892616d1fbce450e402bf2d5 ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.12.0%2Bxpu-cp313-cp313-linux_x86_64.whl#sha256=56f74e7c6c096e1a7ac215eb79ee590b764be3fbba8f4febc145bca47194a083 ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.12.0%2Bxpu-cp310-cp310-win_amd64.whl#sha256=b9779b71457b5a916ae052ed2467c10273cae4862d469b191359173b2038c53e ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.12.0%2Bxpu-cp311-cp311-win_amd64.whl#sha256=7ef8e776c992e4e3ae007ebc108eb4f36b1d1dd9da97ecb308ab7fded89a2659 ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.12.0%2Bxpu-cp312-cp312-win_amd64.whl#sha256=7f1d40febf2b8724adf4ff23866897d87478cc43de2a20f7776dc00be334c464 ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torch @ https://download.pytorch.org/whl/xpu/torch-2.12.0%2Bxpu-cp313-cp313-win_amd64.whl#sha256=32770e2613df26e2c81ae64ea001b2ca12b8d152231285caff9b5f963a21ad75 ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + + "bitsandbytes @ https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_main/bitsandbytes-1.33.7.preview-py3-none-manylinux_2_24_x86_64.whl ; ('linux' in sys_platform) and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "bitsandbytes @ https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_main/bitsandbytes-1.33.7.preview-py3-none-win_amd64.whl ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.27.0%2Bxpu-cp310-cp310-manylinux_2_28_x86_64.whl#sha256=0d517462caf6f5201c0d7c880f4ac431783c88fcc59b4587836da6c72a89509c ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.27.0%2Bxpu-cp311-cp311-manylinux_2_28_x86_64.whl#sha256=4b6feada86aa0bd606904b05898b33538106120d8ed706ba11d0011046534cb8 ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.27.0%2Bxpu-cp312-cp312-manylinux_2_28_x86_64.whl#sha256=e231819be0f87829c2344c909c1f0db9d6ae7d6faefe644a526a1a01d0c18d98 ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.27.0%2Bxpu-cp313-cp313-manylinux_2_28_x86_64.whl#sha256=8bc7d37515cea18af4c389d5fde58b1a9d76b015f2d87e4a7dc62ad50b1cc200 ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.27.0%2Bxpu-cp310-cp310-win_amd64.whl#sha256=65dbb041057dddfe369f29cfaab63f75563621779a23a7b1e2c0ff8a84d4376a ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.27.0%2Bxpu-cp311-cp311-win_amd64.whl#sha256=df647445365924d69fe3bb2a15a7edfe5b63ef91e4ae69af11d93582985237a4 ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.27.0%2Bxpu-cp312-cp312-win_amd64.whl#sha256=b0db3df0d0d154d18ba988ab420f1da2549f9372113ff54ff66e4ae3c7fe3bd0 ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.27.0%2Bxpu-cp313-cp313-win_amd64.whl#sha256=c70850842068c43a0d50eaf139c25b6f6cc9b17a0dae70218c7e69edbee0bc80 ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", +] +intel-gpu-torch2120 = [ + "unsloth[intelgputorch2120]" +] intel = [ "unsloth[intelgputorch280]", ] diff --git a/scripts/check_frontend_dep_removal.py b/scripts/check_frontend_dep_removal.py new file mode 100644 index 0000000000..260ad5215a --- /dev/null +++ b/scripts/check_frontend_dep_removal.py @@ -0,0 +1,1195 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. +"""Guard against breaking npm dependency removals in studio/frontend. + +Diffs the current package.json against a git base, finds every package +that was removed, and confirms each is no longer referenced anywhere +in the repo. If a removed package is still imported and is not +transitively resolvable through the new lockfile, exits non-zero with +file:line citations. + +Usage: + python scripts/check_frontend_dep_removal.py + python scripts/check_frontend_dep_removal.py --base origin/main + python scripts/check_frontend_dep_removal.py --base HEAD~1 + python scripts/check_frontend_dep_removal.py --base-pkg PATH --head-lock PATH + +Exit codes: + 0 every removed dep is safe (no source refs or still resolvable) + 1 at least one removed dep is referenced and not resolvable + 2 invocation error (bad args, missing file, git error) +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +FRONTEND_PKG = "studio/frontend/package.json" +FRONTEND_LOCK = "studio/frontend/package-lock.json" + +DEP_FIELDS = ( + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +) + +# Sources where seeing a package name does NOT count as usage. +EXPECTED_NOISE_FILES = { + "studio/frontend/package.json", + "studio/frontend/package-lock.json", + "studio/backend/core/data_recipe/oxc-validator/package.json", + "studio/backend/core/data_recipe/oxc-validator/package-lock.json", +} + +# Only quoted-string occurrences in these file types can be module specifiers. +JS_LIKE_EXT = re.compile( + r"\.(ts|tsx|js|jsx|mjs|cjs|html|htm|css|scss|sass|json|jsonc)$" +) +# Files where JS-syntactic import patterns (static/dynamic/require/re-export) +# could be a real module reference. Markdown gets a separate gate (.mdx is +# real ESM; .md code fences are not). +SCRIPT_LIKE_EXT = re.compile(r"\.(ts|tsx|js|jsx|mjs|cjs|mdx)$") +STYLE_EXT = re.compile(r"\.(css|scss|sass)$") +HTML_EXT = re.compile(r"\.(html|htm)$") +TS_LIKE_EXT = re.compile(r"\.(ts|tsx|mts|cts|mdx)$") +# Files where a removed package's CLI binary could be invoked (npx, bunx, +# yarn dlx, pnpm exec, or a bare `pkg --flag` shell call). +COMMAND_LIKE_EXT = re.compile(r"(\.(ya?ml|sh|ps1|bat)$|(^|/)Dockerfile[^/]*$)") + +GREP_INCLUDES = [ + "--include=*.ts", + "--include=*.tsx", + "--include=*.js", + "--include=*.jsx", + "--include=*.mjs", + "--include=*.cjs", + "--include=*.html", + "--include=*.htm", + "--include=*.css", + "--include=*.scss", + "--include=*.sass", + "--include=*.json", + "--include=*.jsonc", + "--include=*.md", + "--include=*.mdx", + "--include=*.py", + "--include=*.rs", + "--include=*.toml", + "--include=*.yml", + "--include=*.yaml", + "--include=*.sh", + "--include=*.ps1", + "--include=*.bat", + "--include=Dockerfile*", +] +GREP_EXCLUDES = [ + "--exclude-dir=node_modules", + "--exclude-dir=dist", + "--exclude-dir=.git", + "--exclude-dir=__pycache__", + "--exclude-dir=target", + "--exclude-dir=.next", + "--exclude-dir=build", + "--exclude-dir=.venv", + "--exclude-dir=venv", +] + +# A pip-installed playwright reference is the PyPI package, not npm. +PIP_PLAYWRIGHT = re.compile( + r"(pip\s+install\s+['\"]?playwright" + r"|python\s+-m\s+playwright" + r"|from\s+playwright" + r"|^\s*import\s+playwright)" +) + + +@dataclass +class Hit: + file: str + line: int + kind: str + snippet: str + + +def run(cmd: list[str], cwd: Path | None = None) -> str: + """Run a command, return stdout. On non-zero exit, return ''.""" + res = subprocess.run( + cmd, + cwd = cwd or REPO_ROOT, + stdout = subprocess.PIPE, + stderr = subprocess.PIPE, + text = True, + ) + return res.stdout if res.returncode == 0 else "" + + +def read_pkg_at(base: str, path: str) -> dict: + """Read JSON at `base:path` via git show. Empty dict if missing.""" + out = run(["git", "show", f"{base}:{path}"]) + if not out.strip(): + return {} + return json.loads(out) + + +def read_pkg_file(path: Path) -> dict: + if not path.exists(): + return {} + return json.loads(path.read_text(encoding = "utf-8")) + + +def all_decl_names(pkg: dict) -> set[str]: + names: set[str] = set() + for field in DEP_FIELDS: + names.update((pkg.get(field) or {}).keys()) + return names + + +def _resolve_install_path(parent_path: str, name: str, pkgs: dict) -> str | None: + """Walk up the nested node_modules chain from `parent_path` to find + where `name` actually resolves. Mirrors Node module resolution. + """ + parts = parent_path.split("/node_modules/") + for i in range(len(parts), 0, -1): + prefix = "/node_modules/".join(parts[:i]) + trial = (prefix + "/node_modules/" if prefix else "node_modules/") + name + if trial in pkgs: + return trial + if f"node_modules/{name}" in pkgs: + return f"node_modules/{name}" + return None + + +def _deps_of(meta: dict) -> dict: + """Deps npm actually installs. Optional peers are skipped: npm only + installs them when another package declares the same dep, so for the + purpose of "is this package still reachable" they cannot keep a + removed top-level dep alive on their own. + """ + out = {} + for field in ("dependencies", "optionalDependencies"): + out.update(meta.get(field) or {}) + peer_meta = meta.get("peerDependenciesMeta") or {} + for name, spec in (meta.get("peerDependencies") or {}).items(): + if (peer_meta.get(name) or {}).get("optional"): + continue + out[name] = spec + return out + + +def reachable_from_head(head_pkg: dict, lock: dict) -> set[str]: + """BFS the lockfile dep graph starting from `head_pkg`'s top-level + declared deps. Returns the set of lockfile install paths that survive. + Stale lockfile entries (orphaned by the new package.json) are excluded. + """ + pkgs = lock.get("packages", {}) + if not pkgs: + return set() + roots = all_decl_names(head_pkg) + seen: set[str] = set() + frontier: list[str] = [] + for name in roots: + p = _resolve_install_path("", name, pkgs) + if p: + frontier.append(p) + while frontier: + path = frontier.pop() + if path in seen: + continue + seen.add(path) + meta = pkgs.get(path, {}) + for dep_name in _deps_of(meta): + p = _resolve_install_path(path, dep_name, pkgs) + if p and p not in seen: + frontier.append(p) + return seen + + +def classify(pkg: str, file: str, content: str) -> str | None: + """Return why `content` references `pkg`, or None. + + `content` may span multiple lines (for multi-line imports/exports); + each pattern uses re.DOTALL where it matters. The bare-spec + regexes use a word-boundary check on the package name so that + `foobar` does not match `foo`. + + File-type gating: JS-syntactic patterns only fire on .ts/.tsx/.js/.jsx/ + .mjs/.cjs/.mdx files, so an `import x from "pkg"` snippet inside a + Python test fixture or a Markdown code block is not mistaken for a + real npm usage. CSS patterns only fire on .css/.scss/.sass. HTML + patterns only fire on .html/.htm. + """ + if file in EXPECTED_NOISE_FILES: + return None + + esc = re.escape(pkg) + # Subpath gate: after the package name, the next char must be either + # the closing quote, `/`, or end-of-string. Prevents foo matching foobar. + sub = r"(?:/[^'\"`]*)?" + + flags_dotall = re.DOTALL | re.MULTILINE + + is_script = bool(SCRIPT_LIKE_EXT.search(file)) + is_style = bool(STYLE_EXT.search(file)) + is_html = bool(HTML_EXT.search(file)) + is_ts = bool(TS_LIKE_EXT.search(file)) + + # If the file is none of script / style / html / json (which is the + # quoted-string fallback surface) and is not an mdx file, no classify + # rule applies. This is what gates out Python fixtures, Markdown code + # blocks, shell snippets, etc. + is_json = file.endswith(".json") or file.endswith(".jsonc") + if not (is_script or is_style or is_html or is_json): + return None + + # CSS @import is checked first so it does not collide with the + # side-effect-import regex below. + if is_style and re.search(rf"@import\s+['\"]{esc}{sub}['\"]", content): + return "css_import" + # Static imports: handle multi-line `import { ... } from "pkg"` by + # allowing arbitrary content (newlines included) between `import` + # and `from`. The non-greedy match plus the required `from` keeps + # this scoped to a single statement. + if is_script and re.search( + rf"(?]*src\s*=\s*['\"][^'\"]*/{html_pkg}", content + ): + return "html_script" + if is_html and re.search(rf"]*href\s*=\s*['\"][^'\"]*/{html_pkg}", content): + return "html_link" + # TypeScript triple-slash + if is_ts and re.search( + rf"///\s* list[str]: + """Return a list of warnings if package-lock.json's dep map + disagrees with package.json (i.e., npm install was not re-run). + """ + warnings = [] + if not head_lock: + return warnings + root = head_lock.get("packages", {}).get("", {}) + lock_decl = { + **(root.get("dependencies") or {}), + **(root.get("devDependencies") or {}), + **(root.get("peerDependencies") or {}), + **(root.get("optionalDependencies") or {}), + } + pkg_decl = {} + for f in DEP_FIELDS: + pkg_decl.update(head_pkg.get(f) or {}) + only_in_lock = set(lock_decl) - set(pkg_decl) + only_in_pkg = set(pkg_decl) - set(lock_decl) + if only_in_lock: + warnings.append( + f"lockfile lists deps not in package.json (lockfile stale): {sorted(only_in_lock)}" + ) + if only_in_pkg: + warnings.append( + f"package.json declares deps not in lockfile (run npm install): {sorted(only_in_pkg)}" + ) + return warnings + + +def types_orphan_warnings(head_pkg: dict) -> list[str]: + """Flag @types/ deps where is no longer declared anywhere + in package.json. Removing X without also dropping @types/X leaves + dangling type packages. + """ + decl = set() + for f in DEP_FIELDS: + decl.update((head_pkg.get(f) or {}).keys()) + warnings = [] + for name in decl: + if not name.startswith("@types/"): + continue + # @types/foo provides types for `foo` + # @types/foo-bar provides types for `foo-bar` + # @types/scope__pkg provides types for `@scope/pkg` + target = name[len("@types/") :] + if "__" in target: + scope, sub = target.split("__", 1) + target = f"@{scope}/{sub}" + if target == "node": + continue # Node.js types are always implicit + if target not in decl: + warnings.append( + f"@types/{target.replace('@', '').replace('/', '__')} present but '{target}' is not declared" + ) + return warnings + + +_PKG_JSON_SKIP_KEYS = { + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + "bundleDependencies", + "bundledDependencies", +} + +# Top-level fields whose contents are never package references. We walk +# everything else recursively. +_PKG_JSON_OPAQUE_KEYS = { + "browserslist", # browser queries + "keywords", # free-form strings + "engines", # node/npm version constraints + "engineStrict", # bool + "packageManager", # `pnpm@9.0.0` -- the package manager binary + "volta", # version pins for node/npm/yarn + "files", # paths included in publish + "directories", # paths + "publishConfig", # registry / access config + "config", # generic npm config values + "main", + "module", + "browser", + "types", + "typings", + "type", + "exports", + "imports", + "bin", + "man", # author-side fields (not consumer refs) + "scripts", # handled separately via scripts_bin_refs() + "repository", + "bugs", + "homepage", + "funding", + "author", + "contributors", + "maintainers", + "license", + "licenses", + "name", + "version", + "description", + "private", + "sideEffects", + "workspaces", # paths/globs, NOT pkg names +} + + +def package_json_extra_refs(pkg: dict, target: str) -> list[str]: + """Walk every key/value in package.json EXCEPT the dep declaration + blocks, and return citations for string values or dict keys that + equal `target` (or `target/subpath`). + + Catches the patterns the public dep-checker tools commonly miss: + - `overrides` / `resolutions` / `pnpm.overrides` keys + - `pnpm.patchedDependencies` keys + - `peerDependenciesMeta` keys + - `prettier`: "@my/prettier-config" + - `eslintConfig.extends`: ["..."] / "..." + - `stylelint.extends` / `stylelint.plugins` + - `babel.presets` / `babel.plugins` + - `jest.preset` / `jest.setupFiles` / `jest.transform` + - `commitlint.extends`, `renovate.extends`, `remarkConfig.plugins` + """ + target_sub = target + "/" + cites: list[str] = [] + + def matches(s: object) -> bool: + return isinstance(s, str) and (s == target or s.startswith(target_sub)) + + def walk(obj: object, path: str) -> None: + if isinstance(obj, dict): + for k, v in obj.items(): + # Skip top-level dep declaration fields entirely. + if path == "" and k in _PKG_JSON_SKIP_KEYS: + continue + # Top-level fields whose contents are never package refs. + if path == "" and k in _PKG_JSON_OPAQUE_KEYS: + continue + # Inside `overrides` / `resolutions` / etc., the KEY itself + # is a package reference. + if matches(k): + cites.append(f"{path}.{k}" if path else k) + walk(v, f"{path}.{k}" if path else k) + elif isinstance(obj, list): + for i, v in enumerate(obj): + walk(v, f"{path}[{i}]") + elif isinstance(obj, str): + if matches(obj): + cites.append(f"{path}: {obj}") + + walk(pkg, "") + return cites + + +def build_bin_to_pkg(head_lock: dict) -> dict[str, str]: + """Map a binary name (e.g. 'vite', 'tsc', 'eslint') to the package + that provides it. Built from each lockfile entry's `bin` field. + """ + out: dict[str, str] = {} + if not head_lock: + return out + for path, meta in head_lock.get("packages", {}).items(): + if not path: + continue + name = path.split("node_modules/")[-1] + bins = meta.get("bin") + if isinstance(bins, dict): + for binname in bins: + out.setdefault(binname, name) + elif isinstance(bins, str): + out.setdefault(name.split("/")[-1], name) + return out + + +_SCRIPT_TOKENIZE = re.compile(r"\s*(?:&&|\|\||;|\|(?!\|))\s*") + +# Wrappers that delegate to a real CLI in the same shell word list. +# After stripping env prefixes and (optionally) `npx`/`pnpm exec`/`yarn dlx`/ +# `bunx`, if the leading token is one of these we advance past the +# wrapper's own flags and any further env-prefix tokens, then re-check. +# `cross-env` is the common one; `dotenv-cli` / `dotenvx` use `--` as a +# separator. Wrappers that operate on named npm-scripts (concurrently, +# npm-run-all, run-s, run-p, wireit, turbo, nx) intentionally aren't +# here -- they reference script names, not bin names, so the real bin +# is in the *target* script's chunk which we already tokenize. +_SCRIPT_WRAPPERS = {"cross-env", "dotenv", "dotenvx", "env-cmd"} +_ENV_PREFIX_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") + + +def _next_real_bin(words: list[str], idx: int) -> str | None: + """Walk `words` from `idx`, peeling env-prefix tokens, the leading + package-manager runner (`npx`, `pnpm exec`, etc.), and the known + wrapper bins. Return the next token that looks like the real CLI + binary, or None if the chunk has nothing to look up. + + Recursion depth is bounded by the chunk's word count, so the loop + cannot run away on a pathological wrapper chain. + """ + seen_wrappers: set[str] = set() + while idx < len(words): + # 1. env-prefix run: `FOO=bar BAZ="a b" cmd ...`. shlex has + # already collapsed quoted values into one word, so this + # tokenizer is safe for them. + while idx < len(words) and _ENV_PREFIX_RE.match(words[idx]): + idx += 1 + if idx >= len(words): + return None + + first = words[idx] + # 2. Package-manager runner: `npx args`, `pnpm exec `, + # `yarn dlx `, `bunx `. Strip and continue (so the + # wrapped command goes through the same unwrap loop). + if first in {"npx", "pnpx", "bunx"} and idx + 1 < len(words): + idx += 1 + continue + if ( + first in {"pnpm", "yarn"} + and idx + 2 < len(words) + and words[idx + 1] in {"exec", "dlx"} + ): + idx += 2 + continue + + # 3. Wrapper bin (cross-env, dotenv, etc.). Skip the wrapper's + # own flags and any subsequent env-prefix tokens, then re-loop. + bin_token = first.removeprefix("./node_modules/.bin/").removeprefix( + "node_modules/.bin/" + ) + if bin_token in _SCRIPT_WRAPPERS and bin_token not in seen_wrappers: + seen_wrappers.add(bin_token) + idx += 1 + # cross-env / env-cmd: no flags; just more env-prefix tokens. + # dotenv / dotenvx: skip `-e ` style flags and the + # optional `--` separator before the wrapped command. + while idx < len(words): + tok = words[idx] + if tok.startswith("-") and tok != "--": + idx += 1 + # `-e .env` style: also skip the flag's argument + # when it does not look like another flag. + if ( + idx < len(words) + and not words[idx].startswith("-") + and not _ENV_PREFIX_RE.match(words[idx]) + ): + idx += 1 + continue + if tok == "--": + idx += 1 + break + break + continue + return bin_token + return None + + +def scripts_bin_refs( + head_pkg: dict, bin_to_pkg: dict[str, str] +) -> dict[str, list[str]]: + """Return `{package_name: ['scripts.X: cmd', ...]}` listing every + package referenced via its bin name in package.json scripts. + + Each script value is split on shell separators (`&&`, `||`, `;`, + `|`). Within each chunk, `_next_real_bin()` unwraps env prefixes, + package-manager runners (`npx` / `pnpm exec` / `yarn dlx` / `bunx`), + and wrapper bins like `cross-env` / `dotenv` so that + `cross-env CI=1 biome check` correctly credits `biome` to its + declaring package. + + Tokenization uses shlex.split so quoted env values + (`FOO="a b" biome`) survive unbroken. + """ + import shlex + + scripts = head_pkg.get("scripts", {}) or {} + refs: dict[str, list[str]] = {} + for script_name, raw_cmd in scripts.items(): + if not isinstance(raw_cmd, str): + continue + for chunk in _SCRIPT_TOKENIZE.split(raw_cmd): + chunk = chunk.strip() + if not chunk: + continue + try: + words = shlex.split(chunk, posix = True) + except ValueError: + # Unbalanced quotes -- fall back to plain split. + words = chunk.split() + if not words: + continue + bin_name = _next_real_bin(words, 0) + if bin_name is None: + continue + pkg = bin_to_pkg.get(bin_name) + if pkg: + refs.setdefault(pkg, []).append(f"scripts.{script_name}: {raw_cmd}") + return refs + + +def tsconfig_compiler_types_refs() -> set[str]: + """Read studio/frontend/tsconfig*.json and return the set of + package names referenced in compilerOptions.types arrays. These are + implicitly loaded by tsc and count as a real use even though they + have no explicit import. + """ + out: set[str] = set() + base = REPO_ROOT / "studio/frontend" + for name in ("tsconfig.json", "tsconfig.app.json", "tsconfig.node.json"): + path = base / name + if not path.exists(): + continue + try: + text = path.read_text() + # tsconfig allows comments; strip simple line comments. + text = re.sub(r"//[^\n]*", "", text) + data = json.loads(text) + except (OSError, json.JSONDecodeError): + continue + types = (data.get("compilerOptions", {}) or {}).get("types", []) or [] + for t in types: + if not isinstance(t, str): + continue + # `vite/client` resolves to `vite` package. + pkg = ( + t.split("/", 1)[0] + if not t.startswith("@") + else "/".join(t.split("/", 2)[:2]) + ) + out.add(pkg) + return out + + +def enumerate_dep_usage(head_pkg: dict, head_lock: dict) -> dict[str, list]: + """For every declared dep, classify whether it appears used. Returns + a dict with these categories: + - used: has at least one detected usage in src/, + config files, scripts.bin, package.json + field refs, or tsconfig types + - unused: no detected usage anywhere + - type_pkg_kept: @types/X where X is still declared + - type_pkg_orphan: @types/X where X is no longer declared + (or X is removed) -- candidate for removal + + Each entry is the package name. The categorisation is opinionated; + `unused` is a CANDIDATE list, not a guarantee. The caller should + verify before deletion. + """ + decl = all_decl_names(head_pkg) + bin_to_pkg = build_bin_to_pkg(head_lock) if head_lock else {} + script_refs = scripts_bin_refs(head_pkg, bin_to_pkg) + tsc_types = tsconfig_compiler_types_refs() + + results: dict[str, list] = { + "used": [], + "unused": [], + "type_pkg_kept": [], + "type_pkg_orphan": [], + } + for name in sorted(decl): + if name.startswith("@types/"): + target = name[len("@types/") :] + if "__" in target: + scope, sub = target.split("__", 1) + target = f"@{scope}/{sub}" + if target == "node": + results["type_pkg_kept"].append(name) + elif target in decl: + results["type_pkg_kept"].append(name) + else: + results["type_pkg_orphan"].append(name) + continue + # Real-source-usage check + hits = find_usage(name) + used = bool(hits) + # CLI usage in shell / workflow / Dockerfile surfaces. Skip for + # `@types/*` packages because they never expose a CLI binary and + # the unscoped-tail bin name candidate would scan workflow files + # for the bare runtime name (a removed `@types/foo` would look + # for invocations of `foo`). + if not used and not name.startswith("@types/") and find_command_usage(name): + used = True + # Bin scripts + if not used and name in script_refs: + used = True + # package.json non-dep field references + if not used and package_json_extra_refs(head_pkg, name): + used = True + # tsconfig compilerOptions.types implicit usage + if not used and name in tsc_types: + used = True + if used: + results["used"].append(name) + else: + results["unused"].append(name) + return results + + +def find_imports_without_decl(head_pkg: dict) -> list[tuple[str, int, str]]: + """Reverse check: find bare-specifier imports in studio/frontend/src + that don't correspond to any declared package.json dep. Catches the + case where someone adds an import but forgets the dep declaration. + Returns (file, line, spec) tuples. + + Match shapes covered: + import "pkg" + import Foo from "pkg" + import { Foo } from "pkg" + import type { Foo } from "pkg" + const x = require("pkg") + const x = await import("pkg") + """ + decl = set() + for f in DEP_FIELDS: + decl.update((head_pkg.get(f) or {}).keys()) + # Also: anything tsconfig path-aliases (just '@/...' here) is internal. + # The capture group is the specifier; the leading alternation accepts + # any of: `from "..."`, bare side-effect `import "..."`, + # `import("..."), or `require("...")`. We exclude relative paths and + # the `@/` alias prefix by requiring the first char of the specifier + # to be neither `.` nor `/`. + pattern = ( + r"(?:\bfrom\s+|" + r"\bimport\s+(?:\(\s*)?|" + r"\brequire(?:\.resolve)?\(\s*)" + r"['\"]([^'\"./][^'\"]*)['\"]" + ) + args = [ + "grep", + "-rnE", + pattern, + "--include=*.ts", + "--include=*.tsx", + "--include=*.js", + "--include=*.jsx", + "studio/frontend/src", + ] + out = run(args) + missing = [] + for line in out.splitlines(): + m = re.match(r"^(?:\./)?([^:]+):(\d+):(.*)$", line) + if not m: + continue + file, ln, content = m.group(1), int(m.group(2)), m.group(3) + for spec_match in re.finditer(pattern, content): + spec = spec_match.group(1) + # Resolve to package name (strip subpath) + if spec.startswith("@"): + parts = spec.split("/", 2) + pkg_name = "/".join(parts[:2]) if len(parts) >= 2 else spec + else: + pkg_name = spec.split("/", 1)[0] + if pkg_name in decl: + continue + # Internal aliases like '@/foo' or starts with builtin names + if pkg_name == "@": + continue + if pkg_name in { + "node:fs", + "node:path", + "fs", + "path", + "url", + "stream", + "crypto", + "buffer", + "util", + "events", + "child_process", + }: + continue + missing.append((file, ln, spec)) + return missing + + +def grep_repo(pat: str) -> list[tuple[str, int, str]]: + args = ["grep", "-rnE", pat] + GREP_INCLUDES + GREP_EXCLUDES + ["."] + out = run(args) + rows = [] + for line in out.splitlines(): + m = re.match(r"^(\./)?([^:]+):(\d+):(.*)$", line) + if m: + rows.append((m.group(2), int(m.group(3)), m.group(4))) + return rows + + +_file_lines_cache: dict[str, list[str]] = {} + + +def _read_file(path: str) -> list[str]: + if path not in _file_lines_cache: + try: + _file_lines_cache[path] = ( + Path(path).read_text(errors = "replace").splitlines() + ) + except (OSError, UnicodeDecodeError): + _file_lines_cache[path] = [] + return _file_lines_cache[path] + + +def find_usage(pkg: str) -> list[Hit]: + """Return real usages of `pkg`. Filters pip-playwright separately. + + For each filename returned by grep, also feed a multi-line window + around the matching line into classify() so multi-line imports + (`import {\n a\n} from "pkg"`) get picked up. + """ + rows = grep_repo(re.escape(pkg)) + hits = [] + seen_keys: set[tuple[str, str]] = set() + for file, lineno, content in rows: + if pkg == "playwright" and PIP_PLAYWRIGHT.search(content): + continue + # Try the single-line classify first. + kind = classify(pkg, file, content) + if not kind: + # Multi-line window: a generous 25 lines above + the line + + # 25 below so Prettier's one-import-per-line formatting for + # 12-20+ named imports still includes the `import` keyword + # in the same window as the `from "pkg"` clause. + lines = _read_file(file) + lo = max(0, lineno - 26) + hi = min(len(lines), lineno + 25) + window = "\n".join(lines[lo:hi]) + kind = classify(pkg, file, window) + if kind: + key = (file, kind) + if key in seen_keys: + continue + seen_keys.add(key) + hits.append(Hit(file, lineno, kind, content[:160])) + return hits + + +def _candidate_bin_names(pkg: str) -> set[str]: + """Names a removed package's CLI could be invoked under in shell + scripts and workflow files. Most npm CLIs use the package name + (`vite`, `eslint`, `playwright`); scoped CLI packages commonly + expose an unscoped binary name (`@biomejs/biome` -> `biome`). + """ + return {pkg, pkg.rsplit("/", 1)[-1]} + + +def find_command_usage(pkg: str) -> list[Hit]: + """Find package CLI invocations in shell / workflow / Dockerfile + surfaces: `npx pkg`, `bunx pkg`, `pnpm exec pkg`, `yarn dlx pkg`, + or a bare `pkg --flag`. Returns Hit("command_bin"). + + Detection is bounded to COMMAND_LIKE_EXT files so a JS string that + happens to contain `npx foo` inside a TS test fixture is not + mistaken for a real invocation. + """ + bins = sorted(_candidate_bin_names(pkg), key = len, reverse = True) + esc_bins = "|".join(re.escape(b) for b in bins) + # grep ERE pattern (POSIX classes for whitespace/word boundaries). + # Build without f-strings to avoid f-string-vs-{} confusion with the + # POSIX `[[:space:]]` literals and trailing `})}` boundary class. + grep_pat = ( + r"(^|[[:space:]:;&|(\[])" + r"(npx[[:space:]]+|pnpm[[:space:]]+exec[[:space:]]+" + r"|yarn[[:space:]]+(dlx[[:space:]]+)?|bunx[[:space:]]+)?" + r"(" + esc_bins + r")" + r"([[:space:])};|\]]|$)" + ) + py_pat = re.compile( + r"(^|[\s:;&|(\[])" + r"(?:npx\s+|pnpm\s+exec\s+|yarn\s+(?:dlx\s+)?|bunx\s+)?" + r"(" + esc_bins + r")" + r"([\s)};|\]]|$)" + ) + hits: list[Hit] = [] + seen: set[tuple[str, int]] = set() + for file, lineno, content in grep_repo(grep_pat): + if not COMMAND_LIKE_EXT.search(file): + continue + if pkg == "playwright" and PIP_PLAYWRIGHT.search(content): + continue + if not py_pat.search(content): + continue + key = (file, lineno) + if key in seen: + continue + seen.add(key) + hits.append(Hit(file, lineno, "command_bin", content[:160])) + return hits + + +def types_target_name(pkg: str) -> str | None: + """Strip `@types/` prefix and decode the npm scope-encoding so the + return value matches the runtime package name. `@types/foo` -> `foo`, + `@types/foo__bar` -> `@foo/bar`. Returns None for non-@types packages. + """ + if not pkg.startswith("@types/"): + return None + target = pkg[len("@types/") :] + if "__" in target: + scope, sub = target.split("__", 1) + return f"@{scope}/{sub}" + return target + + +def find_types_runtime_usage(pkg: str, tsc_types: set[str]) -> list[Hit]: + """For a removed `@types/X`, find usages of `X` itself: explicit + `/// `, `tsconfig.compilerOptions.types: ["X"]`, + and runtime `import "X"` shapes. The whole point of `@types/X` is to + type one of those; if any are present, the type package must stay. + """ + target = types_target_name(pkg) + if target is None: + return [] + hits = find_usage(target) + if target in tsc_types: + hits.append( + Hit( + "studio/frontend/tsconfig*.json", + 0, + "tsconfig_types", + f'compilerOptions.types includes "{target}"', + ) + ) + return hits + + +def main() -> int: + p = argparse.ArgumentParser( + description = __doc__, formatter_class = argparse.RawTextHelpFormatter + ) + p.add_argument( + "--base", + default = "origin/main", + help = "git ref to diff against (default: origin/main). " + "Examples: HEAD~1, main, a-tag, a-sha.", + ) + p.add_argument( + "--base-pkg", help = "optional override: read base package.json from this path" + ) + p.add_argument( + "--base-lock", + help = "optional override: read base package-lock.json from this path. " + "Used to recover the bin -> package mapping for removed packages so " + "scripts.foo still flags as a usage even after the PR drops node_modules/foo.", + ) + p.add_argument( + "--head-pkg", + default = str(REPO_ROOT / FRONTEND_PKG), + help = "head package.json path (default: working tree)", + ) + p.add_argument( + "--head-lock", + default = str(REPO_ROOT / FRONTEND_LOCK), + help = "head lockfile path (default: working tree). " + "Reachability analysis runs against this lockfile.", + ) + p.add_argument("--verbose", action = "store_true") + p.add_argument( + "--strict", + action = "store_true", + help = "Also fail on hygiene warnings (lockfile sync, " + "@types orphans, imports without declared dep, unused deps).", + ) + p.add_argument( + "--enumerate-dead", + action = "store_true", + help = "Print every declared dep that appears unused anywhere " + "in the repo. Informational; does not fail unless --strict.", + ) + args = p.parse_args() + + if args.base_pkg: + base_pkg = read_pkg_file(Path(args.base_pkg)) + else: + base_pkg = read_pkg_at(args.base, FRONTEND_PKG) + head_pkg = read_pkg_file(Path(args.head_pkg)) + if not base_pkg: + print( + f"ERROR: could not read base package.json at {args.base}:{FRONTEND_PKG}", + file = sys.stderr, + ) + return 2 + if not head_pkg: + print( + f"ERROR: could not read head package.json at {args.head_pkg}", + file = sys.stderr, + ) + return 2 + + head_lock_path = Path(args.head_lock) + if not head_lock_path.exists(): + print( + f"ERROR: head lockfile not found at {head_lock_path}", + file = sys.stderr, + ) + return 2 + head_lock = read_pkg_file(head_lock_path) + + # Base lockfile is best-effort. We use it only to recover the + # bin -> package mapping for packages the PR is removing -- so a + # `scripts.biome:check` cite still fires when `@biomejs/biome` is + # being dropped and the head lockfile no longer has it. + if args.base_lock: + base_lock_path = Path(args.base_lock) + base_lock = read_pkg_file(base_lock_path) if base_lock_path.exists() else {} + else: + base_lock = read_pkg_at(args.base, FRONTEND_LOCK) + + base_names = all_decl_names(base_pkg) + head_names = all_decl_names(head_pkg) + removed = sorted(base_names - head_names) + + # All hygiene checks compute up front so they can run on both the + # removal-present and removal-empty paths (so `--strict` actually + # fails when only hygiene issues exist). + sync_warns = lockfile_root_sync(head_pkg, head_lock) + types_warns = types_orphan_warnings(head_pkg) + missing_imports = find_imports_without_decl(head_pkg) + enum = enumerate_dep_usage(head_pkg, head_lock) if args.enumerate_dead else None + + def _print_hygiene() -> None: + if sync_warns: + print("Lockfile sync warnings:") + for w in sync_warns: + print(f" - {w}") + print() + if types_warns: + print("@types orphan warnings:") + for w in types_warns: + print(f" - {w}") + print() + if missing_imports: + print( + f"Imports without a matching package.json dep ({len(missing_imports)}):" + ) + for file, ln, spec in missing_imports[:20]: + print(f" - {file}:{ln} imports '{spec}'") + print() + if enum is not None: + print("Dead-dep enumeration:") + if enum["unused"]: + print(f" unused ({len(enum['unused'])}):") + for n in enum["unused"]: + print(f" - {n}") + else: + print(" unused: none") + if enum["type_pkg_orphan"]: + print(f" type_pkg_orphan ({len(enum['type_pkg_orphan'])}):") + for n in enum["type_pkg_orphan"]: + print(f" - {n}") + if args.verbose: + print(f" used: {len(enum['used'])}") + print(f" type_pkg_kept: {len(enum['type_pkg_kept'])}") + print() + + hygiene_strict_fail = args.strict and ( + sync_warns + or types_warns + or missing_imports + or (enum is not None and (enum["unused"] or enum["type_pkg_orphan"])) + ) + + if not removed: + print("[OK] no dependencies removed from studio/frontend/package.json") + if args.enumerate_dead or sync_warns or types_warns or missing_imports: + print() + _print_hygiene() + if hygiene_strict_fail: + print("FAIL (--strict): one or more hygiene warnings present") + return 1 + return 0 + + print( + f"Checking {len(removed)} removed package(s) from studio/frontend/package.json" + ) + print(f"Base: {args.base} Head: working tree") + print() + + reachable_paths = reachable_from_head(head_pkg, head_lock) if head_lock else set() + # bin -> package map: start from the head lockfile, then layer the + # base lockfile's entries on top for packages this PR is removing. + # A correct removal updates the head lockfile to drop node_modules/foo, + # so build_bin_to_pkg(head_lock) loses the mapping; we recover it + # from the base lockfile so `scripts.biome:check` still flags as a + # usage when `@biomejs/biome` is being dropped. + bin_to_pkg = build_bin_to_pkg(head_lock) if head_lock else {} + base_bin_to_pkg = build_bin_to_pkg(base_lock) if base_lock else {} + removed_set = set(removed) + for bin_name, pkg_name in base_bin_to_pkg.items(): + if pkg_name in removed_set: + bin_to_pkg.setdefault(bin_name, pkg_name) + script_refs = scripts_bin_refs(head_pkg, bin_to_pkg) + tsc_types = tsconfig_compiler_types_refs() + + def reachable_install_paths(name: str) -> tuple[str | None, list[str]]: + """Return (top_level_path, nested_paths). top_level is what bare + `import "name"` from src/ actually resolves to; nested copies are + only visible inside the parent package that nested them. + """ + top = f"node_modules/{name}" + top_path = top if top in reachable_paths else None + nested = sorted( + p + for p in reachable_paths + if p != top and p.endswith(f"/node_modules/{name}") + ) + return top_path, nested + + failures: list[tuple[str, list[Hit]]] = [] + for name in removed: + hits = find_usage(name) + # CLI invocations in shell scripts / workflows / Dockerfiles. + hits.extend(find_command_usage(name)) + # @types/X is "used" if X is referenced as a type or as a + # runtime import elsewhere in the repo. + hits.extend(find_types_runtime_usage(name, tsc_types)) + for cite in script_refs.get(name, []): + hits.append(Hit("studio/frontend/package.json", 0, "script_bin", cite)) + for cite in package_json_extra_refs(head_pkg, name): + hits.append(Hit("studio/frontend/package.json", 0, "pkg_json_field", cite)) + top, nested = reachable_install_paths(name) + importable_top_level = top is not None + # Source imports of bare specifier `name` resolve ONLY to top-level + # node_modules/. Nested copies under another package are + # invisible to src/ files. + if hits and not importable_top_level: + status = "FAIL" + elif hits and importable_top_level: + status = "OK-via-transitive" + else: + status = "OK" + print(f" [{status}] {name}") + if top: + print(f" reachable (top-level): {top}") + if nested: + print( + f" reachable (nested, NOT importable from src/): {nested[0]}" + + (f" (+{len(nested)-1} more)" if len(nested) > 1 else "") + ) + if hits: + for h in hits[:5]: + print(f" [{h.kind}] {h.file}:{h.line} {h.snippet}") + if status == "FAIL": + failures.append((name, hits)) + if args.verbose and not hits and not (top or nested): + print(" no references, not reachable -- clean removal") + + print() + + _print_hygiene() + + if failures: + print( + f"FAIL: {len(failures)} removed package(s) still referenced and not resolvable" + ) + for name, _ in failures: + print(f" - {name}") + return 1 + if hygiene_strict_fail: + print("FAIL (--strict): one or more hygiene warnings present") + return 1 + + print("PASS: all removed packages are safe to drop") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/studio/backend/core/inference/external_provider.py b/studio/backend/core/inference/external_provider.py index f5b67eef70..de5f5c5500 100644 --- a/studio/backend/core/inference/external_provider.py +++ b/studio/backend/core/inference/external_provider.py @@ -24,11 +24,18 @@ import structlog # sites use printf-style positional args, which structlog accepts. logger = structlog.get_logger(__name__) -# Claude 4.7 (Opus/Sonnet/Haiku) deprecated top_k and returns 400 -# "top_k is deprecated for this model" when it is set. 3.x and 4.5/4.6 -# still accept it. Match the 4-7 line specifically so we keep the knob -# live on every other Claude generation. -_ANTHROPIC_TOP_K_DEPRECATED = re.compile(r"^claude-(?:opus|sonnet|haiku)-4-7(?:[-.]|$)") +# Claude 4.7 (Opus/Sonnet/Haiku) removed temperature, top_p, and top_k — +# the API returns 400 " is deprecated for this model" if any of +# them is set to a non-default value. The "Sampling parameters removed" +# section of the 4.7 release notes is the authoritative reference: +# https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-7 +# 3.x and 4.5/4.6 still accept all three; match the 4-7 line strictly so +# the knobs keep working on earlier families. The trailing -4-7[-.]/EOL +# anchor keeps future versions (e.g. claude-opus-5) unaffected. +_ANTHROPIC_4_7_SAMPLING_REMOVED = re.compile( + r"^claude-(?:opus|sonnet|haiku)-4-7(?:[-.]|$)" +) +_OPENAI_REASONING_SUMMARY_UNSUPPORTED = re.compile(r"^o3(?:[-.]|$)") class _AnthropicThinkingSpec(NamedTuple): @@ -41,7 +48,7 @@ _ANTHROPIC_THINKING_SPECS = ( _AnthropicThinkingSpec( prefixes = ("claude-opus-4-7",), kind = "adaptive", - efforts = ("none", "low", "medium", "high", "xhigh"), + efforts = ("none", "low", "medium", "high", "xhigh", "max"), ), _AnthropicThinkingSpec( prefixes = ("claude-opus-4-6", "claude-sonnet-4-6"), @@ -141,6 +148,34 @@ def _apply_mistral_reasoning_controls( _http_client = httpx.AsyncClient() +def _build_kimi_tool_end( + synthetic_chunk_fn: Any, + tool_call_id: str, + citations: list[dict[str, str]], +) -> str: + """Format Kimi web_search citations into the tool_end payload. + + Same shape parseSourcesFromResult on the frontend expects for the + other built-in web_search providers: `Title: ...\\nURL: ...\\n + Snippet: ...\\n---\\n...`. If no citations were emitted, fall back + to a generic "(search complete)" string so the UI still shows the + tool card transitioning to a completed state. + """ + blocks: list[str] = [] + for cit in citations: + line = f"Title: {cit['title']}\nURL: {cit['url']}" + if cit.get("snippet"): + line += f"\nSnippet: {cit['snippet']}" + blocks.append(line) + return synthetic_chunk_fn( + { + "type": "tool_end", + "tool_call_id": tool_call_id, + "result": "\n---\n".join(blocks) if blocks else "(search complete)", + } + ) + + class ExternalProviderClient: """Async proxy for OpenAI-compatible external LLM APIs.""" @@ -173,10 +208,11 @@ class ExternalProviderClient: auth_header = provider_info.get("auth_header", "Authorization") auth_prefix = provider_info.get("auth_prefix", "Bearer ") - headers = { - "Content-Type": "application/json", - auth_header: f"{auth_prefix}{self.api_key}", - } + headers = {"Content-Type": "application/json"} + # Skip auth header when api_key is empty (optional for local providers); + # httpx rejects an empty `Bearer ` value as "Illegal header value". + if self.api_key: + headers[auth_header] = f"{auth_prefix}{self.api_key}" # Merge any provider-specific extra headers (e.g. anthropic-version, OpenRouter attribution) headers.update(provider_info.get("extra_headers", {})) return headers @@ -199,6 +235,9 @@ class ExternalProviderClient: top_k: Optional[int] = None, enable_thinking: Optional[bool] = None, reasoning_effort: Optional[str] = None, + enabled_tools: Optional[list[str]] = None, + enable_prompt_caching: Optional[bool] = None, + openai_code_exec_container_id: Optional[str] = None, stream: bool = True, ) -> AsyncGenerator[str, None]: """ @@ -222,6 +261,8 @@ class ExternalProviderClient: top_k, enable_thinking, reasoning_effort, + enabled_tools, + enable_prompt_caching, ): yield line return @@ -240,6 +281,30 @@ class ExternalProviderClient: max_tokens, enable_thinking, reasoning_effort, + enabled_tools, + enable_prompt_caching, + openai_code_exec_container_id, + ): + yield line + return + + # Kimi's $web_search is a builtin_function that requires a client + # round-trip: the first call returns a tool_calls envelope with + # function.arguments populated; the caller echoes those arguments + # back as a role=tool message; the second call streams the final + # answer with the search incorporated. The doc also mandates + # disabling thinking while $web_search is active. Route to a + # dedicated helper so the default OAI-compat path stays single-pass. + # https://platform.kimi.ai/docs/guide/use-web-search + if ( + self.provider_type == "kimi" + and enabled_tools + and "web_search" in enabled_tools + ): + async for line in self._stream_kimi_web_search( + messages, + model, + max_tokens, ): yield line return @@ -293,6 +358,13 @@ class ExternalProviderClient: _apply_mistral_reasoning_controls( body, model, enable_thinking, reasoning_effort ) + elif self.provider_type == "vllm" and enable_thinking is not None: + # vLLM gates thinking via chat_template_kwargs.enable_thinking. + tpl_kw = body.get("chat_template_kwargs") + if not isinstance(tpl_kw, dict): + tpl_kw = {} + tpl_kw["enable_thinking"] = bool(enable_thinking) + body["chat_template_kwargs"] = tpl_kw # OpenRouter exposes a unified `reasoning` parameter on every # chat-completion request — the gateway routes it to whichever @@ -317,6 +389,29 @@ class ExternalProviderClient: else: body["reasoning"] = {"enabled": False} + # OpenRouter web-search plugin — universal shape that works + # for every model id, including the `openrouter/free` and + # `openrouter/auto` meta-routers. Documented at + # https://openrouter.ai/docs/guides/features/plugins/web-search + # The `:online` model-suffix shortcut is "exactly equivalent + # to" this plugin per the same doc, but only works on + # concrete model ids — meta-routers reject the suffix. + # `plugins: [{id: "web"}]` works everywhere, no model id + # rewrite needed, and idempotent if some future call site + # adds the entry first. + if enabled_tools and "web_search" in enabled_tools: + plugins = list(body.get("plugins") or []) + if not any( + isinstance(p, dict) and p.get("id") == "web" for p in plugins + ): + plugins.append({"id": "web"}) + body["plugins"] = plugins + logger.info( + "OpenRouter web_search: attached plugins=[{id: 'web'}] " + "(model=%s)", + body.get("model"), + ) + url = f"{self.base_url}/chat/completions" logger.info( "Proxying chat completion to %s (provider=%s, model=%s)", @@ -362,6 +457,94 @@ class ExternalProviderClient: # error" in the UI with no trail on the server side. event_counts: dict[str, int] = {} chosen_model: Optional[str] = None + # Web-search tool-card synthesis for OpenRouter. The gateway + # doesn't emit structured web_search_call events — citations + # come back as `annotations` of type=url_citation on delta / + # message objects. Mirror the OpenAI/Anthropic UX by yielding + # a synthetic tool_start at stream open and tool_end at + # stream close with the collected citation list. + web_search_active = ( + self.provider_type == "openrouter" + and bool(enabled_tools) + and "web_search" in (enabled_tools or []) + ) + web_search_tool_id = "openrouter_web_search" + web_search_citations: list[dict[str, str]] = [] + web_search_tool_started = False + web_search_tool_ended = False + + def _emit_synthetic_tool_event(payload: dict[str, Any]) -> str: + chunk = { + "id": f"chatcmpl-{self.provider_type}-synthetic", + "object": "chat.completion.chunk", + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": None, + } + ], + "_toolEvent": payload, + } + return f"data: {_json.dumps(chunk)}" + + def _record_or_url_citation(payload: Any) -> None: + if not isinstance(payload, dict): + return + if payload.get("type") != "url_citation": + return + # OpenRouter (and OpenAI Chat Completions web_search) + # nest the citation under url_citation; some variants + # ship the fields flat on the annotation itself. Accept + # both. + cit = payload.get("url_citation") + if not isinstance(cit, dict): + cit = payload + url = cit.get("url", "") if isinstance(cit, dict) else "" + if not url or not isinstance(url, str): + return + if any(c["url"] == url for c in web_search_citations): + return + title = cit.get("title") or url + snippet = cit.get("content") or cit.get("snippet") or "" + web_search_citations.append( + { + "url": url, + "title": title, + "snippet": snippet if isinstance(snippet, str) else "", + } + ) + + def _build_web_search_tool_end() -> str: + blocks: list[str] = [] + for cit in web_search_citations: + line = f"Title: {cit['title']}\nURL: {cit['url']}" + if cit.get("snippet"): + line += f"\nSnippet: {cit['snippet']}" + blocks.append(line) + return _emit_synthetic_tool_event( + { + "type": "tool_end", + "tool_call_id": web_search_tool_id, + "result": ( + "\n---\n".join(blocks) + if blocks + else "(search complete)" + ), + } + ) + + if web_search_active: + yield _emit_synthetic_tool_event( + { + "type": "tool_start", + "tool_name": "web_search", + "tool_call_id": web_search_tool_id, + "arguments": {}, + } + ) + web_search_tool_started = True + try: while True: try: @@ -374,6 +557,17 @@ class ExternalProviderClient: data_str = line[len("data:") :].strip() if data_str == "[DONE]": event_counts["done"] = event_counts.get("done", 0) + 1 + # Emit synthetic tool_end with collected + # citations BEFORE forwarding [DONE], so the + # tool-card transitions to "complete" in the + # UI before the stream closes. + if ( + web_search_active + and web_search_tool_started + and not web_search_tool_ended + ): + yield _build_web_search_tool_end() + web_search_tool_ended = True elif data_str: try: parsed = _json.loads(data_str) @@ -406,17 +600,52 @@ class ExternalProviderClient: parsed.get("model"), str ): chosen_model = parsed["model"] + # When the user has web_search on, scan + # every chunk's delta and message + # objects for url_citation annotations. + # Different OpenRouter upstreams place + # them in different spots. + if web_search_active: + choices = parsed.get("choices") or [] + if isinstance(choices, list): + for choice in choices: + if not isinstance(choice, dict): + continue + for envelope in ( + choice.get("delta"), + choice.get("message"), + ): + if not isinstance(envelope, dict): + continue + for ann in ( + envelope.get("annotations") + or [] + ): + _record_or_url_citation(ann) yield line + # Stream ended without [DONE] (some upstreams just close + # the connection). Emit tool_end so the card doesn't + # stay in "running" forever. + if ( + web_search_active + and web_search_tool_started + and not web_search_tool_ended + ): + yield _build_web_search_tool_end() + web_search_tool_ended = True except GeneratorExit: await response.aclose() # set PoolByteStream._closed=True FIRST await lines_gen.aclose() # now safe — aclose() is a no-op raise finally: logger.info( - "%s stream complete (model=%s, chosen=%s, events=%s)", + "%s stream complete (model=%s, chosen=%s, " + "web_search_requested=%s, citations=%s, events=%s)", self.provider_type, model, chosen_model, + web_search_active, + len(web_search_citations), event_counts, ) await response.aclose() @@ -444,6 +673,384 @@ class ExternalProviderClient: self.provider_type, ) + async def _stream_kimi_web_search( + self, + messages: list[dict[str, Any]], + model: str, + max_tokens: Optional[int], + ) -> AsyncGenerator[str, None]: + """ + Kimi $web_search round-trip. + + Wire flow (per https://platform.kimi.ai/docs/guide/use-web-search): + 1. POST messages with tools=[{type: "builtin_function", + function: {name: "$web_search"}}] and thinking=disabled. + 2. Stream the first response — accumulate function.arguments + across tool_call deltas until finish_reason="tool_calls". + Do NOT forward those tool_call chunks to the client (they + are an internal protocol step, not user-visible output). + 3. Build a second request: original messages + the assistant + message carrying the tool_calls + a role=tool message that + echoes the same arguments back verbatim (per Kimi docs, + the caller "just needs to submit tool_call.function.arguments + to Kimi as they are" — the server actually runs the search). + 4. Stream the second response — that is the final answer the + user sees, with search results already incorporated. + + We synthesize tool_start (with the parsed query) when step (2) + completes, and tool_end (with any url_citation annotations the + second stream emits) before [DONE], so the chat UI shows the + same web-search tool card as the other providers. + """ + url = f"{self.base_url}/chat/completions" + body: dict[str, Any] = { + "model": model, + "messages": messages, + "stream": True, + # $web_search forbids thinking; sending the toggle silently + # would have the server reject the request with 400. + "thinking": {"type": "disabled"}, + "tools": [ + {"type": "builtin_function", "function": {"name": "$web_search"}} + ], + } + if max_tokens is not None: + body["max_tokens"] = max_tokens + + # Strip body fields the Kimi registry declares unusable + # (temperature/top_p — see body_omit in providers.py). + from core.inference.providers import get_provider_info + + provider_info = get_provider_info(self.provider_type) or {} + for field in provider_info.get("body_omit", ()): + body.pop(field, None) + + tool_call_id = "kimi_web_search" + synthetic_id = f"chatcmpl-{self.provider_type}-synthetic" + + def _synthetic_chunk(payload: dict[str, Any]) -> str: + chunk = { + "id": synthetic_id, + "object": "chat.completion.chunk", + "choices": [{"index": 0, "delta": {}, "finish_reason": None}], + "_toolEvent": payload, + } + return f"data: {_json.dumps(chunk)}" + + logger.info( + "Kimi $web_search round-trip starting (model=%s, url=%s)", + model, + url, + ) + + # ---- First call: collect the model's $web_search tool_call ---- + tool_calls_acc: dict[int, dict[str, Any]] = {} + try: + async with _http_client.stream( + "POST", + url, + json = body, + headers = self._auth_headers(), + timeout = self._stream_timeout, + ) as response: + if response.status_code != 200: + error_body = await response.aread() + error_text = error_body.decode("utf-8", errors = "replace") + logger.error( + "Kimi first-call returned %d: %s", + response.status_code, + error_text[:500], + ) + yield _error_sse_line( + response.status_code, error_text, self.provider_type + ) + return + + lines_gen = response.aiter_lines().__aiter__() + try: + while True: + try: + line = await lines_gen.__anext__() + except StopAsyncIteration: + break + if not line.strip() or not line.startswith("data:"): + continue + data_str = line[len("data:") :].strip() + if data_str == "[DONE]": + break + try: + parsed = _json.loads(data_str) + except Exception: + continue + for choice in parsed.get("choices") or []: + if not isinstance(choice, dict): + continue + delta = choice.get("delta") or {} + for tc in delta.get("tool_calls") or []: + if not isinstance(tc, dict): + continue + idx = tc.get("index", 0) + slot = tool_calls_acc.setdefault( + idx, + { + "id": tc.get("id") or f"call_{idx}", + "type": "function", + "function": {"name": "", "arguments": ""}, + }, + ) + if tc.get("id"): + slot["id"] = tc["id"] + fn = tc.get("function") or {} + if fn.get("name"): + slot["function"]["name"] = fn["name"] + if fn.get("arguments"): + slot["function"]["arguments"] += fn["arguments"] + if choice.get("finish_reason") == "tool_calls": + break + except GeneratorExit: + await response.aclose() + await lines_gen.aclose() + raise + finally: + await response.aclose() + await lines_gen.aclose() + except httpx.HTTPError as exc: + logger.error("Kimi first-call HTTP error: %s", exc) + yield _error_sse_line( + 502, + f"Error communicating with kimi: {exc}", + self.provider_type, + ) + return + + # If the model decided not to search, fall back to a plain + # streaming call without the builtin tool. That mirrors the UX + # of every other provider when web_search is on but the model + # didn't actually need it. + search_calls = [ + tc + for tc in tool_calls_acc.values() + if tc["function"]["name"] == "$web_search" + ] + if not search_calls: + logger.info( + "Kimi $web_search: model did not invoke search; " + "falling back to plain stream" + ) + fallback_body = dict(body) + fallback_body.pop("tools", None) + try: + async with _http_client.stream( + "POST", + url, + json = fallback_body, + headers = self._auth_headers(), + timeout = self._stream_timeout, + ) as response: + if response.status_code != 200: + error_body = await response.aread() + error_text = error_body.decode("utf-8", errors = "replace") + logger.error( + "Kimi fallback returned %d: %s", + response.status_code, + error_text[:500], + ) + yield _error_sse_line( + response.status_code, error_text, self.provider_type + ) + return + # Manual __anext__ loop instead of `async for` — see the + # comment in stream_chat_completion for the Python 3.13 + + # httpcore 1.0.x GeneratorExit interaction this avoids. + lines_gen = response.aiter_lines().__aiter__() + try: + while True: + try: + line = await lines_gen.__anext__() + except StopAsyncIteration: + break + if line.strip(): + yield line + except GeneratorExit: + await response.aclose() + await lines_gen.aclose() + raise + finally: + await response.aclose() + await lines_gen.aclose() + except httpx.HTTPError as exc: + logger.error("Kimi fallback HTTP error: %s", exc) + yield _error_sse_line( + 502, + f"Error communicating with kimi: {exc}", + self.provider_type, + ) + return + + # Synthesize tool_start with the parsed search query so the + # chat UI's web-search card shows "Searching for: ...". + first_args_raw = search_calls[0]["function"]["arguments"] or "{}" + try: + first_args = _json.loads(first_args_raw) + except Exception: + first_args = {} + # Log the raw arguments so we can confirm the server actually + # ran the search. The shape is documented loosely but in practice + # the model emits `{"search_result":{"search_id":...}, + # "usage":{"total_tokens":N}}` — an opaque receipt where N is the + # token cost of the injected search context. The query string is + # NOT present; Kimi runs the search server-side during the first + # call and bakes the results straight into the model's context. + logger.info( + "Kimi $web_search: %d tool_call(s), args[0]=%s", + len(search_calls), + first_args_raw[:500], + ) + first_args_search_tokens: Optional[int] = None + if isinstance(first_args, dict): + usage_block = first_args.get("usage") + if isinstance(usage_block, dict): + tok = usage_block.get("total_tokens") + if isinstance(tok, int): + first_args_search_tokens = tok + yield _synthetic_chunk( + { + "type": "tool_start", + "tool_name": "web_search", + "tool_call_id": tool_call_id, + "arguments": first_args if isinstance(first_args, dict) else {}, + } + ) + # Kimi's search has already executed server-side by the time the + # first call returns (the tool_call envelope encodes the search + # result reference, not a query for us to dispatch). Emit + # tool_end NOW so the UI's web-search card transitions to + # "complete" before the second call starts streaming the + # answer, instead of after — otherwise the card sits in + # "running" all the way through the answer streaming and the + # user perceives the model answering before search finishes. + yield _build_kimi_tool_end(_synthetic_chunk, tool_call_id, []) + + # ---- Second call: echo the tool_calls back and stream answer ---- + assistant_msg = { + "role": "assistant", + "content": "", + "tool_calls": list(tool_calls_acc.values()), + } + tool_msgs = [ + { + "role": "tool", + "tool_call_id": tc["id"], + "name": tc["function"]["name"], + "content": tc["function"]["arguments"], + } + for tc in tool_calls_acc.values() + ] + followup_body = dict(body) + followup_body["messages"] = list(messages) + [assistant_msg] + tool_msgs + # Ask the SSE stream to include a final `usage` block so we can + # see prompt_tokens (which jumps to thousands when the server + # injects search context). Without this, OpenAI-compat streams + # omit usage entirely. Kimi follows the same convention. + followup_body["stream_options"] = {"include_usage": True} + # Keep the tool definition on the second call so the model can + # decide to search again mid-turn if needed. Kimi's doc shows + # the same tools array on every step. + + try: + async with _http_client.stream( + "POST", + url, + json = followup_body, + headers = self._auth_headers(), + timeout = self._stream_timeout, + ) as response: + if response.status_code != 200: + error_body = await response.aread() + error_text = error_body.decode("utf-8", errors = "replace") + logger.error( + "Kimi second-call returned %d: %s", + response.status_code, + error_text[:500], + ) + yield _error_sse_line( + response.status_code, error_text, self.provider_type + ) + return + + lines_gen = response.aiter_lines().__aiter__() + # Diagnostics: latch usage.prompt_tokens from the final + # chunk. The Kimi docs say search results count toward + # prompt_tokens, so a big value here is direct evidence + # the server actually injected results into context. + last_usage: Optional[dict[str, Any]] = None + annotation_shapes: set[str] = set() + try: + while True: + try: + line = await lines_gen.__anext__() + except StopAsyncIteration: + break + if not line.strip(): + continue + if line.startswith("data:"): + data_str = line[len("data:") :].strip() + if data_str and data_str != "[DONE]": + try: + parsed = _json.loads(data_str) + except Exception: + parsed = None + if isinstance(parsed, dict): + usage = parsed.get("usage") + if isinstance(usage, dict): + last_usage = usage + # Scan annotations only for diagnostics — + # Kimi today doesn't emit url_citation, but + # if a future model version starts to we'll + # see the type name in the final log line + # and can wire it into the tool_end payload. + for choice in parsed.get("choices") or []: + if not isinstance(choice, dict): + continue + for envelope in ( + choice.get("delta"), + choice.get("message"), + ): + if not isinstance(envelope, dict): + continue + for ann in ( + envelope.get("annotations") or [] + ): + if isinstance(ann, dict): + annotation_shapes.add( + str(ann.get("type") or "?") + ) + yield line + except GeneratorExit: + await response.aclose() + await lines_gen.aclose() + raise + finally: + logger.info( + "Kimi $web_search complete (model=%s, " + "search_ctx_tokens=%s, annotation_types=%s, " + "prompt_tokens=%s, completion_tokens=%s)", + model, + first_args_search_tokens, + sorted(annotation_shapes) or None, + (last_usage or {}).get("prompt_tokens"), + (last_usage or {}).get("completion_tokens"), + ) + await response.aclose() + await lines_gen.aclose() + except httpx.HTTPError as exc: + logger.error("Kimi second-call HTTP error: %s", exc) + yield _error_sse_line( + 502, + f"Error communicating with kimi: {exc}", + self.provider_type, + ) + async def _stream_anthropic( self, messages: list[dict[str, Any]], @@ -454,6 +1061,8 @@ class ExternalProviderClient: top_k: Optional[int] = None, enable_thinking: Optional[bool] = None, reasoning_effort: Optional[str] = None, + enabled_tools: Optional[list[str]] = None, + enable_prompt_caching: Optional[bool] = None, ) -> AsyncGenerator[str, None]: """ Call the Anthropic Messages API and translate its SSE to OpenAI format. @@ -523,24 +1132,79 @@ class ExternalProviderClient: else: filtered.append(msg) + # Claude 4.7 family removed temperature / top_p / top_k entirely. + # The earlier guard only handled top_k; temperature is now also + # rejected with 400 "temperature is deprecated for this model". + # Latch the match once and reuse it everywhere temperature or + # top_k would otherwise be set — including the thinking-mode + # override below, which used to force temperature=1. + sampling_removed = bool(_ANTHROPIC_4_7_SAMPLING_REMOVED.match(model)) + body: dict[str, Any] = { "model": model, "messages": filtered, "max_tokens": max_tokens or 1024, # required by Anthropic - "temperature": temperature, "stream": True, } - # top_k is deprecated on Claude 4.7 (Opus/Sonnet/Haiku) — the API - # returns 400 "top_k is deprecated for this model" when it is set. - # 3.x and 4.5/4.6 still accept it, so gate strictly on the 4.7 ids. - if ( - top_k is not None - and top_k > 0 - and not _ANTHROPIC_TOP_K_DEPRECATED.match(model) - ): + if not sampling_removed: + body["temperature"] = temperature + if top_k is not None and top_k > 0 and not sampling_removed: body["top_k"] = top_k + # Anthropic only caches a prefix when at least one cache_control + # marker is attached to it — the frontend defaults + # enable_prompt_caching to True for Anthropic, so treat `None` the + # same as True here (callers that don't set the flag still get + # caching). Pass False explicitly to opt out. + prompt_caching_enabled = enable_prompt_caching is not False + if system: - body["system"] = system + if prompt_caching_enabled: + # System block is the most stable prefix across turns, so + # it gets its own breakpoint. Skipped when system is + # empty — there's nothing to cache, and an empty marker + # is a no-op. + body["system"] = [ + { + "type": "text", + "text": system, + "cache_control": {"type": "ephemeral"}, + } + ] + else: + body["system"] = system + + if prompt_caching_enabled and filtered: + # Second breakpoint at the end of the conversation. Anthropic + # caches the longest matching prefix up to a cache_control + # marker; placing one on the latest message means turn N+1 + # rehydrates everything up through turn N from cache instead + # of recomputing it. This is what makes caching actually work + # when the system prompt is empty or shorter than Anthropic's + # ~1024-token cache floor — the conversation history carries + # the bulk of the input tokens. Anthropic allows up to 4 + # breakpoints per request; we use at most 2 (system + tail). + last_msg = filtered[-1] + content = last_msg.get("content") + if isinstance(content, str): + last_msg["content"] = [ + { + "type": "text", + "text": content, + "cache_control": {"type": "ephemeral"}, + } + ] + elif isinstance(content, list) and content: + # Don't mutate the caller's list. Rebuild the tail with + # cache_control attached to the final block so an + # upstream image-bearing turn still cleanly slots into + # the cache as part of the conversational prefix. + head = list(content[:-1]) + tail = content[-1] + if isinstance(tail, dict): + head.append({**tail, "cache_control": {"type": "ephemeral"}}) + else: + head.append(tail) + last_msg["content"] = head thinking_spec = _anthropic_thinking_spec(model) allowed_efforts = ( thinking_spec.efforts @@ -566,13 +1230,15 @@ class ExternalProviderClient: if effort and effort != "none": # Anthropic rejects top_k whenever thinking is enabled. body.pop("top_k", None) - # Anthropic requires temperature=1 whenever thinking is enabled, - # AND forbids top_p in the same request: setting both produces + # Earlier families (4.5/4.6) require temperature=1 when + # thinking is enabled and forbid top_p in the same request: # "temperature and top_p cannot both be specified for this # model. Please use only one." - # The base body never sets top_p, but pop defensively in case - # an upstream edit ever adds it before this branch runs. - body["temperature"] = 1 + # On Claude 4.7, temperature was removed entirely — sending + # any value (including 1) returns 400 — so skip the override + # there and let the model use its default sampling. + if not sampling_removed: + body["temperature"] = 1 body.pop("top_p", None) if thinking_spec and thinking_spec.kind == "adaptive": # `display` defaults to "omitted" on Claude Opus 4.7 (per the @@ -602,6 +1268,52 @@ class ExternalProviderClient: if body.get("max_tokens", 0) <= budget_tokens: body["max_tokens"] = budget_tokens + 1024 + # Anthropic server-side web_search — see + # https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/web-search-tool + # The tool type is date-pinned (web_search_20250305 today) and + # Anthropic dispatches search calls server-side, returning + # server_tool_use + web_search_tool_result blocks in the SSE + # stream, plus url-citation annotations on text deltas. We + # translate all of that into our local _toolEvent shape so the + # chat UI renders web_search exactly like OpenAI's path. + if enabled_tools and "web_search" in enabled_tools: + anthropic_tools = list(body.get("tools") or []) + anthropic_tools.append( + { + "type": "web_search_20250305", + "name": "web_search", + "max_uses": 5, + } + ) + body["tools"] = anthropic_tools + + # Anthropic server-side code execution — see + # https://platform.claude.com/docs/en/agents-and-tools/tool-use/code-execution-tool + # `code_execution_20250825` runs Python + bash + str_replace + # file edits inside a 5 GB sandboxed container per request, with + # no internet access. The tool entry itself takes no extra + # parameters; on the SSE stream Anthropic emits two sub-tool + # names — `bash_code_execution` and + # `text_editor_code_execution` — wrapped in the standard + # server_tool_use / *_tool_result block shape. The matching + # beta header (`code-execution-2025-08-25`) is set further down + # in this function alongside the request headers. + # v1 wires the tool only; file uploads (container_upload + # content blocks and generated-file retrieval via the Files + # API) are a deliberate follow-up. + code_execution_enabled = bool( + enabled_tools and "code_execution" in enabled_tools + ) + if code_execution_enabled: + anthropic_tools = list(body.get("tools") or []) + anthropic_tools.append( + { + "type": "code_execution_20250825", + "name": "code_execution", + } + ) + body["tools"] = anthropic_tools + url = f"{self.base_url}/messages" completion_id = f"chatcmpl-anthropic-{model.replace('/', '-')}" @@ -631,12 +1343,29 @@ class ExternalProviderClient: logger.info("Proxying Anthropic Messages API to %s (model=%s)", url, model) + request_headers = self._auth_headers() + if code_execution_enabled: + # Anthropic accepts comma-separated beta features in a single + # `anthropic-beta` header. Merge our flag onto whatever the + # registry's extra_headers contributed (currently nothing on + # the beta axis, just anthropic-version) so future betas + # added at the registry level keep working. + existing_beta = request_headers.get("anthropic-beta", "").strip() + beta_parts = ( + [p.strip() for p in existing_beta.split(",") if p.strip()] + if existing_beta + else [] + ) + if "code-execution-2025-08-25" not in beta_parts: + beta_parts.append("code-execution-2025-08-25") + request_headers["anthropic-beta"] = ",".join(beta_parts) + try: async with _http_client.stream( "POST", url, json = body, - headers = self._auth_headers(), + headers = request_headers, timeout = self._stream_timeout, ) as response: if response.status_code != 200: @@ -659,6 +1388,46 @@ class ExternalProviderClient: # "no thinking content" — distinguishes "Anthropic never sent # thinking_delta" from "frontend didn't render the chunks". event_counts: dict[str, int] = {} + # web_search state. Anthropic emits the query inside an + # `input_json_delta` stream on a `server_tool_use` content + # block, then a separate `web_search_tool_result` block + # with the URL list. Unlike OpenAI we get per-call results + # directly, so each tool card carries its own citations. + # `current_server_tool_use`: {id, name, partial_json_buffer} + # `current_result_block`: {tool_use_id, results} + # Both go to None when the matching content_block_stop fires. + current_server_tool_use: Optional[dict[str, Any]] = None + current_result_block: Optional[dict[str, Any]] = None + web_search_calls: dict[str, dict[str, Any]] = {} + # code_execution state. Anthropic's + # `code_execution_20250825` tool emits the same + # server_tool_use → *_tool_result block shape as + # web_search, but the server_tool_use carries one of + # two sub-tool names (`bash_code_execution` or + # `text_editor_code_execution`) and the result block + # type matches (`bash_code_execution_tool_result` / + # `text_editor_code_execution_tool_result`). Kept + # parallel to web_search state so the two paths don't + # collide when both pills are on in the same turn. + current_code_exec_use: Optional[dict[str, Any]] = None + current_code_exec_result: Optional[dict[str, Any]] = None + code_execution_calls: dict[str, dict[str, Any]] = {} + # Counts surfaced in the final log line so reports of + # "Code execution did nothing" can be triaged at a + # glance. generated_files_count is interesting for the + # future Files API PR — when bash creates files inside + # the container, they show up as file_id entries on + # bash_code_execution_result.content, and v1 drops + # them. Track the count so we know how often it would + # have mattered. + code_execution_generated_files = 0 + # Cache usage tracking. message_start carries the input + # accounting (incl. cache_creation_input_tokens and + # cache_read_input_tokens); message_delta carries cumulative + # output_tokens. Both are surfaced in the "stream complete" + # log so prompt caching can be verified per-request without + # opening the Anthropic dashboard. + last_usage: dict[str, Any] = {} def _content_chunk(text: str) -> str: chunk = { @@ -674,6 +1443,79 @@ class ExternalProviderClient: } return f"data: {_json.dumps(chunk)}" + def _emit_tool_event(payload: dict[str, Any]) -> str: + chunk = { + "id": completion_id, + "object": "chat.completion.chunk", + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": None, + } + ], + "_toolEvent": payload, + } + return f"data: {_json.dumps(chunk)}" + + def _format_web_search_results( + results: list[Any], + ) -> str: + blocks: list[str] = [] + for r in results: + if not isinstance(r, dict): + continue + if r.get("type") != "web_search_result": + continue + url = r.get("url", "") + title = r.get("title") or url + if not url: + continue + blocks.append(f"Title: {title}\nURL: {url}") + return "\n---\n".join(blocks) + + def _format_code_execution_result( + inner: dict[str, Any], + ) -> str: + """Render an Anthropic code-execution result block as + the preformatted text payload the frontend's + CodeExecutionToolUI displays inside a
. Handles
+                    bash, text_editor (view/create/str_replace), and the
+                    matching error variants.
+                    """
+                    inner_type = inner.get("type") or ""
+                    if inner_type.endswith("_error"):
+                        return f"Error: {inner.get('error_code', 'unknown')}"
+                    if inner_type == "bash_code_execution_result":
+                        stdout = inner.get("stdout") or ""
+                        stderr = inner.get("stderr") or ""
+                        return_code = inner.get("return_code")
+                        parts: list[str] = []
+                        if stdout:
+                            parts.append(stdout)
+                        if stderr:
+                            parts.append(f"--- stderr ---\n{stderr}")
+                        if isinstance(return_code, int) and return_code != 0:
+                            parts.append(f"return_code: {return_code}")
+                        return "\n".join(parts) if parts else "(no output)"
+                    if inner_type == "text_editor_code_execution_result":
+                        # view: file content; create: is_file_update flag;
+                        # str_replace: diff `lines` list. The matching
+                        # server_tool_use carries the command + path, but
+                        # that's encoded into the tool_start arguments
+                        # already — here we only format the result body.
+                        if "lines" in inner and isinstance(inner.get("lines"), list):
+                            return "\n".join(str(line) for line in inner["lines"])
+                        if "is_file_update" in inner:
+                            return (
+                                "Updated" if inner.get("is_file_update") else "Created"
+                            )
+                        content_field = inner.get("content")
+                        if isinstance(content_field, str):
+                            return content_field
+                        return "(file operation complete)"
+                    return "(code execution complete)"
+
                 try:
                     while True:
                         try:
@@ -702,7 +1544,88 @@ class ExternalProviderClient:
                             key = event_type or ""
                         event_counts[key] = event_counts.get(key, 0) + 1
 
-                        if event_type == "content_block_delta":
+                        # message_start carries the input-side usage block
+                        # including cache_creation_input_tokens and
+                        # cache_read_input_tokens. message_delta updates
+                        # output_tokens (and may overwrite the input fields
+                        # with final values). Merge both into last_usage.
+                        if event_type == "message_start":
+                            start_usage = (event.get("message") or {}).get("usage")
+                            if isinstance(start_usage, dict):
+                                last_usage.update(start_usage)
+
+                        if event_type == "content_block_start":
+                            content_block = event.get("content_block") or {}
+                            block_type = content_block.get("type")
+                            block_name = content_block.get("name")
+                            if (
+                                block_type == "server_tool_use"
+                                and block_name == "web_search"
+                            ):
+                                tool_use_id = content_block.get("id", "") or (
+                                    f"ws_{len(web_search_calls)}"
+                                )
+                                current_server_tool_use = {
+                                    "id": tool_use_id,
+                                    "buffer": "",
+                                }
+                                web_search_calls[tool_use_id] = {
+                                    "query": "",
+                                    "results": [],
+                                }
+                            elif block_type == "web_search_tool_result":
+                                tool_use_id = content_block.get("tool_use_id", "")
+                                # Anthropic sometimes ships the full results
+                                # list on the start event; sometimes deltas
+                                # follow. Capture whatever is present and
+                                # finalize on content_block_stop.
+                                content = content_block.get("content") or []
+                                current_result_block = {
+                                    "tool_use_id": tool_use_id,
+                                    "results": list(content)
+                                    if isinstance(content, list)
+                                    else [],
+                                }
+                            elif block_type == "server_tool_use" and block_name in (
+                                "bash_code_execution",
+                                "text_editor_code_execution",
+                            ):
+                                tool_use_id = content_block.get("id", "") or (
+                                    f"ce_{len(code_execution_calls)}"
+                                )
+                                kind = (
+                                    "bash"
+                                    if block_name == "bash_code_execution"
+                                    else "text_editor"
+                                )
+                                current_code_exec_use = {
+                                    "id": tool_use_id,
+                                    "kind": kind,
+                                    "buffer": "",
+                                }
+                                code_execution_calls[tool_use_id] = {
+                                    "kind": kind,
+                                    "arguments": {},
+                                    "result": None,
+                                }
+                            elif block_type in (
+                                "bash_code_execution_tool_result",
+                                "text_editor_code_execution_tool_result",
+                            ):
+                                # Anthropic ships the full result content
+                                # on the start event for code-exec result
+                                # blocks (unlike web_search, which can
+                                # split across deltas). Capture it and
+                                # finalize on content_block_stop so the
+                                # ordering matches the web_search path.
+                                tool_use_id = content_block.get("tool_use_id", "")
+                                inner = content_block.get("content") or {}
+                                current_code_exec_result = {
+                                    "tool_use_id": tool_use_id,
+                                    "inner": inner if isinstance(inner, dict) else {},
+                                }
+
+                        elif event_type == "content_block_delta":
                             delta = event.get("delta", {})
                             delta_type = delta.get("type")
                             if delta_type == "thinking_delta":
@@ -730,20 +1653,153 @@ class ExternalProviderClient:
                                 text = delta.get("text", "")
                                 if text:
                                     yield _content_chunk(text)
+                                # Citations on text deltas are attached
+                                # per-call by Anthropic via the
+                                # `web_search_tool_result` block; we don't
+                                # need to scrape them off the text events.
+                            elif delta_type == "input_json_delta":
+                                # Streamed partial_json carrying tool inputs
+                                # — the search query for web_search, or the
+                                # command/path/etc. for code execution.
+                                # Route to whichever buffer is open. The two
+                                # state slots are exclusive in practice
+                                # (Anthropic doesn't interleave tool input
+                                # streams), but checking both keeps the
+                                # dispatch robust if that ever changes.
+                                partial = delta.get("partial_json", "")
+                                if current_server_tool_use is not None:
+                                    current_server_tool_use["buffer"] += partial
+                                elif current_code_exec_use is not None:
+                                    current_code_exec_use["buffer"] += partial
                             # signature_delta and any other delta types are
                             # intentionally skipped — they carry trust /
                             # verification metadata, not user-visible content.
 
                         elif event_type == "content_block_stop":
-                            # Close the  tag when the thinking block
-                            # ends, in case no text_delta follows (e.g.
-                            # display=omitted on Claude 4.7, or thinking-only
-                            # turns).
-                            if thinking_open:
+                            if current_server_tool_use is not None:
+                                # End of the server_tool_use block — parse the
+                                # accumulated input_json into a query and
+                                # emit tool_start. The matching tool_end fires
+                                # later when the web_search_tool_result block
+                                # closes with the actual results.
+                                buffer = current_server_tool_use["buffer"]
+                                query = ""
+                                if buffer:
+                                    try:
+                                        parsed = _json.loads(buffer)
+                                        if isinstance(parsed, dict):
+                                            q = parsed.get("query", "")
+                                            if isinstance(q, str):
+                                                query = q
+                                    except Exception:
+                                        query = ""
+                                tool_use_id = current_server_tool_use["id"]
+                                if tool_use_id in web_search_calls:
+                                    web_search_calls[tool_use_id]["query"] = query
+                                yield _emit_tool_event(
+                                    {
+                                        "type": "tool_start",
+                                        "tool_name": "web_search",
+                                        "tool_call_id": tool_use_id,
+                                        "arguments": (
+                                            {"query": query} if query else {}
+                                        ),
+                                    }
+                                )
+                                current_server_tool_use = None
+                            elif current_result_block is not None:
+                                # End of a web_search_tool_result — emit
+                                # tool_end carrying the search results as
+                                # Title:/URL: blocks. parseSourcesFromResult
+                                # on the frontend lifts these into source
+                                # pills at message tail.
+                                tool_use_id = current_result_block["tool_use_id"]
+                                results = current_result_block["results"]
+                                if tool_use_id in web_search_calls:
+                                    web_search_calls[tool_use_id]["results"] = results
+                                result_text = _format_web_search_results(results)
+                                yield _emit_tool_event(
+                                    {
+                                        "type": "tool_end",
+                                        "tool_call_id": tool_use_id,
+                                        "result": (result_text or "(search complete)"),
+                                    }
+                                )
+                                current_result_block = None
+                            elif current_code_exec_use is not None:
+                                # End of a code-execution server_tool_use —
+                                # parse the buffered input_json into a
+                                # {command, path, ...} dict and emit
+                                # tool_start. The matching tool_end fires
+                                # on the result block's content_block_stop.
+                                buffer = current_code_exec_use["buffer"]
+                                parsed_args: dict[str, Any] = {}
+                                if buffer:
+                                    try:
+                                        parsed_obj = _json.loads(buffer)
+                                        if isinstance(parsed_obj, dict):
+                                            parsed_args = parsed_obj
+                                    except Exception:
+                                        parsed_args = {}
+                                tool_use_id = current_code_exec_use["id"]
+                                kind = current_code_exec_use["kind"]
+                                emit_args = {"kind": kind, **parsed_args}
+                                if tool_use_id in code_execution_calls:
+                                    code_execution_calls[tool_use_id]["arguments"] = (
+                                        emit_args
+                                    )
+                                yield _emit_tool_event(
+                                    {
+                                        "type": "tool_start",
+                                        "tool_name": "code_execution",
+                                        "tool_call_id": tool_use_id,
+                                        "arguments": emit_args,
+                                    }
+                                )
+                                current_code_exec_use = None
+                            elif current_code_exec_result is not None:
+                                # End of a code-execution result block —
+                                # format the inner result into the text
+                                # payload CodeExecutionToolUI renders.
+                                tool_use_id = current_code_exec_result["tool_use_id"]
+                                inner = current_code_exec_result["inner"]
+                                # Track generated-file count for the
+                                # follow-up Files API PR. v1 drops them.
+                                if isinstance(inner, dict):
+                                    file_blocks = inner.get("content")
+                                    if isinstance(file_blocks, list):
+                                        for entry in file_blocks:
+                                            if isinstance(entry, dict) and entry.get(
+                                                "file_id"
+                                            ):
+                                                code_execution_generated_files += 1
+                                result_text = _format_code_execution_result(
+                                    inner if isinstance(inner, dict) else {}
+                                )
+                                if tool_use_id in code_execution_calls:
+                                    code_execution_calls[tool_use_id]["result"] = (
+                                        result_text
+                                    )
+                                yield _emit_tool_event(
+                                    {
+                                        "type": "tool_end",
+                                        "tool_call_id": tool_use_id,
+                                        "result": result_text,
+                                    }
+                                )
+                                current_code_exec_result = None
+                            elif thinking_open:
+                                # Close the  tag when the thinking block
+                                # ends, in case no text_delta follows (e.g.
+                                # display=omitted on Claude 4.7, or thinking-
+                                # only turns).
                                 yield _content_chunk("")
                                 thinking_open = False
 
                         elif event_type == "message_delta":
+                            delta_usage = event.get("usage")
+                            if isinstance(delta_usage, dict):
+                                last_usage.update(delta_usage)
                             stop_reason = event.get("delta", {}).get("stop_reason")
                             if stop_reason:
                                 if thinking_open:
@@ -778,16 +1834,57 @@ class ExternalProviderClient:
                     await lines_gen.aclose()  # now safe — aclose() is a no-op
                     raise
                 finally:
-                    # Surface per-event-type counts so reports of "no
-                    # reasoning panel content" can be triaged at a glance:
-                    # zero `content_block_delta:thinking_delta` entries
-                    # means Anthropic skipped thinking for this prompt
-                    # (adaptive can choose to); non-zero means thinking
-                    # arrived and we wrapped it — any visual gap is then
-                    # on the frontend.
+                    # Surface per-event-type counts + web_search summary so
+                    # reports of "no reasoning panel content" / "Search
+                    # didn't do anything" can be triaged at a glance.
+                    web_search_requested = bool(
+                        enabled_tools and "web_search" in enabled_tools
+                    )
+                    web_search_invocations = len(web_search_calls)
+                    total_results = sum(
+                        len(sc.get("results") or []) for sc in web_search_calls.values()
+                    )
+                    queries = [
+                        sc["query"]
+                        for sc in web_search_calls.values()
+                        if sc.get("query")
+                    ]
+                    # cache_read_input_tokens > 0 on turn N proves the
+                    # cache_control marker on the system block is doing
+                    # its job — turn 1 will show cache_creation > 0
+                    # instead. cache_creation tokens are billed at a
+                    # small premium; cache_read tokens are billed at a
+                    # discount.
+                    code_execution_invocations = len(code_execution_calls)
+                    code_execution_results = sum(
+                        1
+                        for c in code_execution_calls.values()
+                        if c.get("result") is not None
+                    )
                     logger.info(
-                        "Anthropic stream event counts (model=%s): %s",
+                        "Anthropic stream complete (model=%s, "
+                        "web_search_requested=%s, web_search_invocations=%s, "
+                        "results=%s, queries=%s, "
+                        "code_execution_requested=%s, "
+                        "code_execution_invocations=%s, "
+                        "code_execution_results=%s, "
+                        "code_execution_generated_files=%s, "
+                        "input_tokens=%s, output_tokens=%s, "
+                        "cache_creation_input_tokens=%s, "
+                        "cache_read_input_tokens=%s, events=%s)",
                         model,
+                        web_search_requested,
+                        web_search_invocations,
+                        total_results,
+                        queries,
+                        code_execution_enabled,
+                        code_execution_invocations,
+                        code_execution_results,
+                        code_execution_generated_files,
+                        last_usage.get("input_tokens"),
+                        last_usage.get("output_tokens"),
+                        last_usage.get("cache_creation_input_tokens"),
+                        last_usage.get("cache_read_input_tokens"),
                         event_counts,
                     )
                     await response.aclose()
@@ -824,6 +1921,9 @@ class ExternalProviderClient:
         max_tokens: Optional[int],
         enable_thinking: Optional[bool],
         reasoning_effort: Optional[str],
+        enabled_tools: Optional[list[str]] = None,
+        enable_prompt_caching: Optional[bool] = None,
+        openai_code_exec_container_id: Optional[str] = None,
     ) -> AsyncGenerator[str, None]:
         """
         Call OpenAI's /v1/responses endpoint and translate its SSE stream back
@@ -900,6 +2000,9 @@ class ExternalProviderClient:
         # to wrap, and the chat reasoning panel stays blank. Always pair
         # an explicit effort with summary except for the explicit "off"
         # case (effort: "none"), where summaries are pointless.
+        summary_unsupported = bool(
+            _OPENAI_REASONING_SUMMARY_UNSUPPORTED.match(model.strip().lower())
+        )
         if reasoning_effort in (
             "minimal",
             "low",
@@ -908,16 +2011,81 @@ class ExternalProviderClient:
             "max",
             "xhigh",
         ):
-            body["reasoning"] = {"effort": reasoning_effort, "summary": "auto"}
+            body["reasoning"] = {"effort": reasoning_effort}
+            if not summary_unsupported:
+                body["reasoning"]["summary"] = "auto"
         elif reasoning_effort == "none" or enable_thinking is False:
             body["reasoning"] = {"effort": "none"}
         elif enable_thinking is True:
-            body["reasoning"] = {"effort": "medium", "summary": "auto"}
+            body["reasoning"] = {"effort": "medium"}
+            if not summary_unsupported:
+                body["reasoning"]["summary"] = "auto"
         if instructions_parts:
             body["instructions"] = "\n\n".join(instructions_parts)
         if max_tokens is not None:
             body["max_output_tokens"] = max_tokens
 
+        # Prompt caching on /v1/responses is automatic and free, but the
+        # default in-memory policy only survives ~5-10 min of inactivity
+        # (up to ~1 hr). Opt into the 24-hour retention policy so a chat
+        # left idle overnight still hits the cache on the next turn.
+        # Pricing is identical to in_memory per OpenAI's docs.
+        #
+        # Gated on the base URL because ollama / llama.cpp / "custom"
+        # presets all collapse to provider_type="openai" in
+        # toExternalBackendProviderType, so they also land in this
+        # helper. Those servers expose /v1/responses-shaped routes in
+        # some configurations but don't implement
+        # prompt_cache_retention — sending the field unconditionally
+        # would 400 them. Match the public OpenAI host strictly so the
+        # field only goes to OpenAI cloud. Studio's openai model picker
+        # is registry-scoped to gpt-5.x / o3 / gpt-4.5, all of which
+        # accept this parameter (gpt-5.5+ already defaults to "24h" and
+        # rejects "in_memory", so it's a safe no-op there).
+        is_openai_cloud = "api.openai.com" in (self.base_url or "")
+        if is_openai_cloud and enable_prompt_caching is not False:
+            body["prompt_cache_retention"] = "24h"
+
+        # OpenAI server-side tools — see
+        #   https://developers.openai.com/api/docs/guides/tools
+        #   https://developers.openai.com/api/docs/guides/tools-shell
+        # The frontend's Search/Code buttons map to the unified
+        # enabled_tools shorthand; translate that into the Responses-API
+        # tool schema. Other built-in tools (file_search,
+        # code_interpreter, image_generation, computer_use_preview) can
+        # be added with the same pattern when we surface their toggles.
+        code_execution_enabled_openai = bool(
+            enabled_tools and "code_execution" in enabled_tools and is_openai_cloud
+        )
+        if enabled_tools:
+            tools_array: list[dict[str, Any]] = []
+            if "web_search" in enabled_tools:
+                tools_array.append({"type": "web_search"})
+            if code_execution_enabled_openai:
+                # `container_auto` lets OpenAI auto-create a fresh
+                # container per request; we capture the resulting
+                # container_id off the SSE stream and the chat-adapter
+                # persists it onto the thread record. Subsequent turns
+                # in the same thread pass it back as
+                # `openai_code_exec_container_id`, which we translate to
+                # `container_reference` here so the model sees
+                # filesystem state from prior turns. Container expires
+                # after ~20 min of inactivity per OpenAI's default
+                # policy — a stale id 400s, the chat-adapter clears it
+                # via container_invalidated, and the next turn falls
+                # back to auto-create.
+                shell_env: dict[str, Any]
+                if openai_code_exec_container_id:
+                    shell_env = {
+                        "type": "container_reference",
+                        "container_id": openai_code_exec_container_id,
+                    }
+                else:
+                    shell_env = {"type": "container_auto"}
+                tools_array.append({"type": "shell", "environment": shell_env})
+            if tools_array:
+                body["tools"] = tools_array
+
         url = f"{self.base_url}/responses"
         completion_id = f"chatcmpl-openai-{model.replace('/', '-')}"
 
@@ -939,6 +2107,29 @@ class ExternalProviderClient:
                         response.status_code,
                         error_text[:500],
                     )
+                    # Detect stale-container errors so the frontend can
+                    # drop its persisted id. OpenAI doesn't pin an
+                    # error code in the public docs for this case, so
+                    # match a couple of likely substrings. If we sent
+                    # a container_reference and the response is 4xx
+                    # with any hint of "container not found / expired",
+                    # emit container_invalidated; the next turn will
+                    # fall back to container_auto.
+                    if (
+                        openai_code_exec_container_id
+                        and 400 <= response.status_code < 500
+                    ):
+                        lowered = error_text.lower()
+                        if "container" in lowered and (
+                            "expired" in lowered
+                            or "not_found" in lowered
+                            or "not found" in lowered
+                            or "no such container" in lowered
+                        ):
+                            yield (
+                                f"data: "
+                                f"{_json.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'choices': [{'index': 0, 'delta': {}, 'finish_reason': None}], '_toolEvent': {'type': 'container_invalidated'}})}"
+                            )
                     yield _error_sse_line(
                         response.status_code, error_text, self.provider_type
                     )
@@ -950,6 +2141,131 @@ class ExternalProviderClient:
                 done_emitted = False
                 reasoning_open = False
                 reasoning_emitted = False
+                # Latched from response.completed / response.incomplete so
+                # the final log can surface input_tokens_details.cached_tokens —
+                # the field that proves prompt_cache_retention="24h" is
+                # actually hitting OpenAI's cache instead of recomputing
+                # the prefix every turn.
+                last_usage: Optional[dict[str, Any]] = None
+                # Per-call state for OpenAI's server-side web_search tool. Mapped
+                # back into our local _toolEvent shape so the existing chat-UI
+                # renderer surfaces web_search the same way it does for local
+                # tool calls: a "Searching…" tool-call card, then a `tool_end`
+                # carrying citations formatted as
+                #   Title: …\nURL: …\nSnippet: …\n---\n…
+                # blocks (which the frontend's parseSourcesFromResult lifts
+                # into source content parts at end of stream).
+                # web_search_calls preserves insertion order so we can apply
+                # the aggregated citation list onto the *last* call's
+                # tool_end — that's the one the frontend's source-pill
+                # extraction reads (parseSourcesFromResult flatMaps every
+                # web_search result, so a single non-empty result is enough
+                # to surface all sources at message tail).
+                # OpenAI emits url_citation annotations on text deltas, not
+                # per call — there's no wire field linking a citation back
+                # to a specific search invocation. Hence the shared list.
+                # web_search_calls: { item_id -> {query} }
+                web_search_calls: dict[str, dict[str, Any]] = {}
+                all_url_citations: list[dict[str, str]] = []
+                # Shell-tool (code execution) state. OpenAI emits
+                # `shell_call` items (model requesting a command list)
+                # paired with `shell_call_output` items (execution
+                # results). We mirror the Anthropic code-execution UX
+                # by emitting one `_toolEvent` tool_start per
+                # shell_call and one tool_end per shell_call_output;
+                # they're linked via `shell_call_output.call_id`
+                # matching `shell_call.id`. Items are independent of
+                # web_search (different keyed map).
+                # shell_calls: { call_id -> {commands, output} }
+                shell_calls: dict[str, dict[str, Any]] = {}
+                # Container id captured from the response stream. When
+                # it differs from the inbound id, emit a synthetic
+                # `container_ready` _toolEvent so the frontend can
+                # persist it onto the thread record for the next turn.
+                # Where OpenAI surfaces it is documented loosely; we
+                # probe two known fields (response.container_id on
+                # response.completed, item.environment.container_id on
+                # shell_call output items) and latch the first one we
+                # see.
+                latched_container_id: Optional[str] = None
+                container_id_emitted = False
+
+                def _emit_tool_event(payload: dict[str, Any]) -> str:
+                    chunk = {
+                        "id": completion_id,
+                        "object": "chat.completion.chunk",
+                        "choices": [
+                            {
+                                "index": 0,
+                                "delta": {},
+                                "finish_reason": None,
+                            }
+                        ],
+                        "_toolEvent": payload,
+                    }
+                    return f"data: {_json.dumps(chunk)}"
+
+                def _format_shell_output(output: Any) -> str:
+                    """Render an OpenAI `shell_call_output.output` list
+                    as the preformatted text payload the frontend's
+                    CodeExecutionToolUI displays inside a 
. Each
+                    entry has stdout/stderr/outcome — concatenate them
+                    with a separator block per entry and append
+                    `return_code` / `(timeout)` annotations only when
+                    they convey information beyond "succeeded".
+                    """
+                    if not isinstance(output, list):
+                        return ""
+                    parts: list[str] = []
+                    for entry in output:
+                        if not isinstance(entry, dict):
+                            continue
+                        stdout = entry.get("stdout") or ""
+                        stderr = entry.get("stderr") or ""
+                        outcome = entry.get("outcome") or {}
+                        chunk_parts: list[str] = []
+                        if stdout:
+                            chunk_parts.append(stdout)
+                        if stderr:
+                            chunk_parts.append(f"--- stderr ---\n{stderr}")
+                        if isinstance(outcome, dict):
+                            outcome_type = outcome.get("type")
+                            if outcome_type == "exit":
+                                exit_code = outcome.get("exit_code")
+                                if isinstance(exit_code, int) and exit_code != 0:
+                                    chunk_parts.append(f"return_code: {exit_code}")
+                            elif outcome_type == "timeout":
+                                chunk_parts.append("(timeout)")
+                        if chunk_parts:
+                            parts.append("\n".join(chunk_parts))
+                    return (
+                        "\n--- next command ---\n".join(parts)
+                        if parts
+                        else "(no output)"
+                    )
+
+                def _record_url_citation(payload: dict[str, Any]) -> None:
+                    """Append a url_citation onto the shared all_url_citations
+                    list. Dedup by URL — the same source can be cited multiple
+                    times across deltas. We do NOT try to attribute citations
+                    to individual web_search_call invocations because OpenAI's
+                    annotation events don't carry that linkage."""
+                    if payload.get("type") != "url_citation":
+                        return
+                    url = payload.get("url", "")
+                    if not url:
+                        return
+                    if any(c["url"] == url for c in all_url_citations):
+                        return
+                    title = payload.get("title") or url
+                    snippet = payload.get("snippet") or payload.get("quote") or ""
+                    all_url_citations.append(
+                        {
+                            "url": url,
+                            "title": title,
+                            "snippet": snippet,
+                        }
+                    )
 
                 def _extract_reasoning_text(payload: Any) -> str:
                     if payload is None:
@@ -1023,13 +2339,70 @@ class ExternalProviderClient:
                                     yield _chunk_with_text("")
                                     reasoning_open = False
                                 yield _chunk_with_text(delta_text)
+                            # Some API versions inline url citations on the
+                            # delta event itself rather than as a separate
+                            # response.output_text.annotation.added event.
+                            for ann in event.get("annotations") or []:
+                                if isinstance(ann, dict):
+                                    _record_url_citation(ann)
 
-                        elif event_type == "response.output_item.done":
+                        elif event_type == "response.output_text.annotation.added":
+                            ann = event.get("annotation")
+                            if isinstance(ann, dict):
+                                _record_url_citation(ann)
+
+                        elif event_type == "response.output_item.added":
+                            # Track the call early but do NOT emit tool_start
+                            # yet — action.query is not reliably populated on
+                            # added across OpenAI API versions, and the
+                            # frontend's tool_start is a one-shot push (no
+                            # update mechanism). Wait for output_item.done.
                             item = event.get("item", {})
                             if (
                                 isinstance(item, dict)
-                                and item.get("type") == "reasoning"
+                                and item.get("type") == "web_search_call"
                             ):
+                                item_id = item.get("id", "") or (
+                                    f"ws_{len(web_search_calls)}"
+                                )
+                                web_search_calls.setdefault(item_id, {"query": ""})
+                            # Shell-tool: register the call eagerly so
+                            # the matching shell_call_output can link
+                            # back even if `done` arrives out of order.
+                            # Also probe for container_id on the
+                            # environment field — when container_auto
+                            # auto-creates one, this is the first place
+                            # the new id might surface (OpenAI doesn't
+                            # promise this in docs, but the field is
+                            # cheap to scan and lets us emit
+                            # container_ready earlier than
+                            # response.completed).
+                            if (
+                                isinstance(item, dict)
+                                and item.get("type") == "shell_call"
+                            ):
+                                item_id = item.get("id", "") or (
+                                    f"sc_{len(shell_calls)}"
+                                )
+                                shell_calls.setdefault(
+                                    item_id,
+                                    {"commands": [], "output": None},
+                                )
+                                env = item.get("environment")
+                                if isinstance(env, dict):
+                                    probe = env.get("container_id") or env.get("id")
+                                    if (
+                                        isinstance(probe, str)
+                                        and probe.startswith("cntr_")
+                                        and latched_container_id is None
+                                    ):
+                                        latched_container_id = probe
+
+                        elif event_type == "response.output_item.done":
+                            item = event.get("item", {})
+                            if not isinstance(item, dict):
+                                continue
+                            if item.get("type") == "reasoning":
                                 summary_text = _extract_reasoning_text(
                                     item.get("summary")
                                 )
@@ -1039,6 +2412,105 @@ class ExternalProviderClient:
                                         reasoning_open = True
                                     yield _chunk_with_text(summary_text)
                                     reasoning_emitted = True
+                            elif item.get("type") == "web_search_call":
+                                # done is the canonical place to read the
+                                # query, so emit both tool_start and tool_end
+                                # here. Frontend then renders a card per call
+                                # with the proper "Searching: " label.
+                                # Citations are aggregated separately and the
+                                # *last* call's result is overwritten at
+                                # response.completed with the citation list
+                                # (so the source-pill extraction at message
+                                # tail surfaces them once).
+                                item_id = item.get("id", "") or (
+                                    f"ws_{len(web_search_calls)}"
+                                )
+                                action = item.get("action")
+                                query = (
+                                    action.get("query", "")
+                                    if isinstance(action, dict)
+                                    else ""
+                                )
+                                web_search_calls[item_id] = {"query": query}
+                                yield _emit_tool_event(
+                                    {
+                                        "type": "tool_start",
+                                        "tool_name": "web_search",
+                                        "tool_call_id": item_id,
+                                        "arguments": (
+                                            {"query": query} if query else {}
+                                        ),
+                                    }
+                                )
+                                yield _emit_tool_event(
+                                    {
+                                        "type": "tool_end",
+                                        "tool_call_id": item_id,
+                                        # Empty result — the last call gets
+                                        # overwritten with citations at
+                                        # response.completed.
+                                        "result": "",
+                                    }
+                                )
+                            elif item.get("type") == "shell_call":
+                                # OpenAI ships the commands array on the
+                                # action field. Join them onto one
+                                # command string for the tool card —
+                                # the renderer is shared with Anthropic
+                                # bash, which only carries a single
+                                # `command`. Multiple commands in one
+                                # shell_call get joined with newlines so
+                                # they still render as one card.
+                                item_id = item.get("id", "") or (
+                                    f"sc_{len(shell_calls)}"
+                                )
+                                action = item.get("action") or {}
+                                commands = (
+                                    action.get("commands")
+                                    if isinstance(action, dict)
+                                    else None
+                                ) or []
+                                joined_command = (
+                                    "\n".join(str(c) for c in commands)
+                                    if isinstance(commands, list)
+                                    else ""
+                                )
+                                shell_calls.setdefault(
+                                    item_id,
+                                    {"commands": [], "output": None},
+                                )
+                                shell_calls[item_id]["commands"] = (
+                                    list(commands) if isinstance(commands, list) else []
+                                )
+                                yield _emit_tool_event(
+                                    {
+                                        "type": "tool_start",
+                                        "tool_name": "code_execution",
+                                        "tool_call_id": item_id,
+                                        "arguments": {
+                                            "kind": "bash",
+                                            "command": joined_command,
+                                        },
+                                    }
+                                )
+                            elif item.get("type") == "shell_call_output":
+                                # `call_id` links back to the shell_call's
+                                # `id`, which is what we used as the
+                                # tool_call_id on tool_start. Match on
+                                # call_id when present so the matching
+                                # card transitions to complete.
+                                call_id = item.get("call_id") or item.get("id") or ""
+                                output = item.get("output") or []
+                                if call_id in shell_calls:
+                                    shell_calls[call_id]["output"] = output
+                                result_text = _format_shell_output(output)
+                                yield _emit_tool_event(
+                                    {
+                                        "type": "tool_end",
+                                        "tool_call_id": call_id,
+                                        "result": result_text,
+                                    }
+                                )
 
                         elif isinstance(event_type, str) and "reasoning" in event_type:
                             reasoning_delta = _extract_reasoning_text(event)
@@ -1050,9 +2522,71 @@ class ExternalProviderClient:
                                 reasoning_emitted = True
 
                         elif event_type == "response.completed":
+                            completed_usage = (event.get("response") or {}).get("usage")
+                            if isinstance(completed_usage, dict):
+                                last_usage = completed_usage
                             if reasoning_open:
                                 yield _chunk_with_text("")
                                 reasoning_open = False
+                            # Probe response.container_id (top-level) and
+                            # response.container.id for the shell-tool
+                            # container id. OpenAI's docs don't pin the
+                            # exact field, so we scan both. Emit
+                            # `container_ready` only when the value
+                            # differs from the inbound one — no churn on
+                            # reuse.
+                            response_obj = event.get("response") or {}
+                            if isinstance(response_obj, dict):
+                                probe_id = response_obj.get("container_id")
+                                if not probe_id:
+                                    container_field = response_obj.get("container")
+                                    if isinstance(container_field, dict):
+                                        probe_id = container_field.get("id")
+                                if (
+                                    isinstance(probe_id, str)
+                                    and probe_id.startswith("cntr_")
+                                    and latched_container_id is None
+                                ):
+                                    latched_container_id = probe_id
+                            if (
+                                latched_container_id
+                                and not container_id_emitted
+                                and latched_container_id
+                                != openai_code_exec_container_id
+                            ):
+                                yield _emit_tool_event(
+                                    {
+                                        "type": "container_ready",
+                                        "container_id": latched_container_id,
+                                    }
+                                )
+                                container_id_emitted = True
+                            # Apply the aggregated citation list onto the
+                            # *last* web_search call by overwriting its
+                            # tool_end result. The frontend's
+                            # parseSourcesFromResult flatMaps every
+                            # web_search tool-call result, so a single
+                            # non-empty result is enough to surface the
+                            # whole source-pill set at the message tail —
+                            # no need to fan out across every card (which
+                            # would just duplicate the same pills).
+                            if web_search_calls and all_url_citations:
+                                last_id = list(web_search_calls.keys())[-1]
+                                blocks: list[str] = []
+                                for cit in all_url_citations:
+                                    line = (
+                                        f"Title: {cit['title']}\n" f"URL: {cit['url']}"
+                                    )
+                                    if cit.get("snippet"):
+                                        line += f"\nSnippet: {cit['snippet']}"
+                                    blocks.append(line)
+                                yield _emit_tool_event(
+                                    {
+                                        "type": "tool_end",
+                                        "tool_call_id": last_id,
+                                        "result": "\n---\n".join(blocks),
+                                    }
+                                )
                             chunk = {
                                 "id": completion_id,
                                 "object": "chat.completion.chunk",
@@ -1067,9 +2601,37 @@ class ExternalProviderClient:
                             yield f"data: {_json.dumps(chunk)}"
 
                         elif event_type == "response.incomplete":
+                            incomplete_usage = (event.get("response") or {}).get(
+                                "usage"
+                            )
+                            if isinstance(incomplete_usage, dict):
+                                last_usage = incomplete_usage
                             if reasoning_open:
                                 yield _chunk_with_text("")
                                 reasoning_open = False
+                            # Same backfill as response.completed — apply
+                            # whatever citations we managed to gather
+                            # before truncation onto the last call. All
+                            # earlier tool cards already have their proper
+                            # query + empty placeholder result from the
+                            # output_item.done emissions above.
+                            if web_search_calls and all_url_citations:
+                                last_id = list(web_search_calls.keys())[-1]
+                                blocks = []
+                                for cit in all_url_citations:
+                                    line = (
+                                        f"Title: {cit['title']}\n" f"URL: {cit['url']}"
+                                    )
+                                    if cit.get("snippet"):
+                                        line += f"\nSnippet: {cit['snippet']}"
+                                    blocks.append(line)
+                                yield _emit_tool_event(
+                                    {
+                                        "type": "tool_end",
+                                        "tool_call_id": last_id,
+                                        "result": "\n---\n".join(blocks),
+                                    }
+                                )
                             chunk = {
                                 "id": completion_id,
                                 "object": "chat.completion.chunk",
@@ -1103,6 +2665,62 @@ class ExternalProviderClient:
                     await lines_gen.aclose()
                     raise
                 finally:
+                    # Summarise what the model actually did this turn so
+                    # support reports of "I clicked Search and got nothing"
+                    # can be triaged at a glance: was the tool requested,
+                    # did OpenAI invoke it, and how many sources came back?
+                    web_search_requested = bool(
+                        enabled_tools and "web_search" in enabled_tools
+                    )
+                    web_search_invocations = len(web_search_calls)
+                    total_citations = len(all_url_citations)
+                    queries = [
+                        sc["query"]
+                        for sc in web_search_calls.values()
+                        if sc.get("query")
+                    ]
+                    # cached_input_tokens > 0 on turn N proves
+                    # prompt_cache_retention="24h" is letting the previous
+                    # turn's prefix hit the cache instead of being
+                    # recomputed. On /v1/responses the field is nested as
+                    # usage.input_tokens_details.cached_tokens (not
+                    # prompt_tokens_details, which is the /v1/chat/completions
+                    # shape).
+                    cached_input_tokens = None
+                    if isinstance(last_usage, dict):
+                        details = last_usage.get("input_tokens_details")
+                        if isinstance(details, dict):
+                            cached_input_tokens = details.get("cached_tokens")
+                    code_execution_requested = code_execution_enabled_openai
+                    code_execution_invocations = len(shell_calls)
+                    code_execution_results = sum(
+                        1 for sc in shell_calls.values() if sc.get("output") is not None
+                    )
+                    logger.info(
+                        "OpenAI Responses stream complete (model=%s, "
+                        "web_search_requested=%s, web_search_invocations=%s, "
+                        "citations=%s, queries=%s, reasoning_emitted=%s, "
+                        "code_execution_requested=%s, "
+                        "code_execution_invocations=%s, "
+                        "code_execution_results=%s, "
+                        "container_id_in=%s, container_id_out=%s, "
+                        "input_tokens=%s, output_tokens=%s, "
+                        "cached_input_tokens=%s)",
+                        model,
+                        web_search_requested,
+                        web_search_invocations,
+                        total_citations,
+                        queries,
+                        reasoning_emitted,
+                        code_execution_requested,
+                        code_execution_invocations,
+                        code_execution_results,
+                        openai_code_exec_container_id,
+                        latched_container_id,
+                        (last_usage or {}).get("input_tokens"),
+                        (last_usage or {}).get("output_tokens"),
+                        cached_input_tokens,
+                    )
                     await response.aclose()
                     await lines_gen.aclose()
 
@@ -1219,6 +2837,123 @@ class ExternalProviderClient:
             )
             raise
 
+    def _container_headers(self) -> dict[str, str]:
+        """Auth headers plus the OpenAI-Beta opt-in for /v1/containers.
+
+        OpenAI's containers API requires ``OpenAI-Beta: containers=v1``.
+        Without it, DELETE silently no-ops: the API returns 200 with a
+        ``{"deleted": true}`` body but does not actually remove the
+        container (verified 2026-05-15). The header is required for
+        list / create / delete to behave consistently.
+        """
+        headers = self._auth_headers()
+        headers["OpenAI-Beta"] = "containers=v1"
+        return headers
+
+    async def list_openai_containers(self) -> list[dict[str, Any]]:
+        """
+        GET /v1/containers on the user's OpenAI account.
+
+        Returns the raw container records (id, name, created_at,
+        last_active_at, expires_after, status). The route layer
+        reshapes these into the UI summary shape.
+
+        Only valid against api.openai.com — non-cloud OpenAI-compat
+        servers don't implement /v1/containers and would 404 here.
+        Caller is responsible for the is_openai_cloud guard.
+        """
+        response = await _http_client.get(
+            f"{self.base_url}/containers",
+            headers = self._container_headers(),
+            timeout = self._timeout,
+        )
+        response.raise_for_status()
+        data = response.json()
+        containers = data.get("data") if isinstance(data, dict) else None
+        result = list(containers) if isinstance(containers, list) else []
+        logger.info(
+            "openai_container_list.response count=%s items=%s",
+            len(result),
+            [
+                {"id": c.get("id"), "status": c.get("status")}
+                for c in result
+                if isinstance(c, dict)
+            ],
+        )
+        return result
+
+    async def create_openai_container(
+        self,
+        name: str,
+        ttl_minutes: int,
+    ) -> dict[str, Any]:
+        """
+        POST /v1/containers with ``expires_after.anchor="last_active_at"``.
+        ``ttl_minutes`` is the idle timeout — every API call that
+        touches the container resets the timer.
+        """
+        body = {
+            "name": name,
+            "expires_after": {
+                "anchor": "last_active_at",
+                "minutes": ttl_minutes,
+            },
+        }
+        response = await _http_client.post(
+            f"{self.base_url}/containers",
+            json = body,
+            headers = self._container_headers(),
+            timeout = self._timeout,
+        )
+        response.raise_for_status()
+        return response.json()
+
+    async def delete_openai_container(self, container_id: str) -> None:
+        """DELETE /v1/containers/{id}. 404s are surfaced as HTTPError.
+
+        Uses a fresh httpx client (not the shared ``_http_client``) so
+        connection-pool state from earlier chat requests cannot
+        interfere — observed in the wild that DELETEs over the shared
+        pool returned ``deleted: true`` while the container persisted
+        in subsequent /containers list calls, even though the same
+        DELETE issued from a fresh client genuinely removed it.
+
+        Verifies the response body reports ``deleted: true``. OpenAI
+        returns a 2xx ``deleted: true`` body even when the request is
+        silently rejected (e.g. missing OpenAI-Beta header), so a
+        status-only check is not sufficient.
+        """
+        url = f"{self.base_url}/containers/{container_id}"
+        headers = self._container_headers()
+        logger.info(
+            "openai_container_delete.outbound url=%s has_auth=%s openai_beta=%s",
+            url,
+            "Authorization" in headers,
+            headers.get("OpenAI-Beta"),
+        )
+        async with httpx.AsyncClient(timeout = self._timeout) as fresh_client:
+            response = await fresh_client.delete(url, headers = headers)
+        logger.info(
+            "openai_container_delete.response status=%s cf_ray=%s "
+            "request_id=%s organization=%s project=%s processing_ms=%s body=%s",
+            response.status_code,
+            response.headers.get("cf-ray"),
+            response.headers.get("x-request-id"),
+            response.headers.get("openai-organization"),
+            response.headers.get("openai-project"),
+            response.headers.get("openai-processing-ms"),
+            response.text[:300],
+        )
+        response.raise_for_status()
+        try:
+            payload = response.json()
+        except ValueError:
+            payload = None
+        if not (isinstance(payload, dict) and payload.get("deleted") is True):
+            raise httpx.HTTPError(
+                f"OpenAI did not confirm container deletion: {response.text[:200]}"
+            )
+
     async def close(self) -> None:
         """No-op — the underlying client is shared across requests."""
 
diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py
index 8e1ec2cef6..5994b6ab4e 100644
--- a/studio/backend/core/inference/llama_cpp.py
+++ b/studio/backend/core/inference/llama_cpp.py
@@ -470,6 +470,17 @@ class LlamaCppBackend:
         # their own cache (Gemma 3n / Gemma 4: .attention.shared_kv_layers).
         self._shared_kv_layers: Optional[int] = None
         self._lock = threading.Lock()
+        # Wraps load_model() end-to-end so concurrent loads serialise
+        # and never coexist as two llama-server processes (#5401).
+        self._serial_load_lock = threading.Lock()
+        # Last extra_args / requested n_ctx, preserved across unload so
+        # the chat UI's /unload+/load Apply path can inherit them (#5401).
+        # ``_extra_args_source`` records the (model_identifier, hf_variant)
+        # the stored args came from so the route can refuse cross-model
+        # inheritance.
+        self._extra_args: Optional[List[str]] = None
+        self._extra_args_source: Optional[tuple[str, Optional[str]]] = None
+        self._requested_n_ctx: int = 0
         self._stdout_lines: list[str] = []
         self._stdout_thread: Optional[threading.Thread] = None
         self._cancel_event = threading.Event()
@@ -505,6 +516,25 @@ class LlamaCppBackend:
     def hf_variant(self) -> Optional[str]:
         return self._hf_variant
 
+    @property
+    def extra_args(self) -> Optional[List[str]]:
+        """Extra llama-server flags from the last load. Copy; None = never
+        set, [] = explicitly cleared. Used by the route for inheritance."""
+        return list(self._extra_args) if self._extra_args is not None else None
+
+    @property
+    def requested_n_ctx(self) -> int:
+        """n_ctx the last load was invoked with (not the effective cap).
+        0 means Auto. Used by the route to detect Auto-vs-explicit flips."""
+        return self._requested_n_ctx
+
+    @property
+    def extra_args_source(self) -> Optional[tuple[str, Optional[str]]]:
+        """(model_identifier, hf_variant) the stored extra_args came from.
+        ``None`` if no extras have ever been recorded. Used by the route
+        to refuse cross-model inheritance (#5401)."""
+        return self._extra_args_source
+
     @property
     def context_length(self) -> Optional[int]:
         """Return the effective context length the server is running at."""
@@ -2008,632 +2038,769 @@ class LlamaCppBackend:
 
         Returns True if server started and health check passed.
         """
-        self._cancel_event.clear()
-
-        # ── Phase 1: kill old process (under lock, fast) ──────────
-        with self._lock:
-            self._kill_process()
-
-        binary = self._find_llama_server_binary()
-        if not binary:
-            raise RuntimeError(
-                "llama-server binary not found. "
-                "Run setup.sh to build it, install llama.cpp, "
-                "or set LLAMA_SERVER_PATH environment variable."
-            )
-
-        # ── Phase 2: download (NO lock held, so cancel can proceed) ──
-        if hf_repo:
-            model_path = self._download_gguf(
-                hf_repo = hf_repo,
+        # Serialise the whole load so concurrent /load calls never
+        # leave two llama-server processes alive (#5401 / #5161). Does
+        # not block /unload, /status, /load-progress.
+        with self._serial_load_lock:
+            # Duplicate /load that raced past the route-level check
+            # (the first one hadn't published _healthy=True yet). If the
+            # live server already satisfies this request, do nothing.
+            if self._already_in_target_state(
+                gguf_path = gguf_path,
+                model_identifier = model_identifier,
                 hf_variant = hf_variant,
-                hf_token = hf_token,
-            )
-            # Auto-download mmproj for vision models
-            if is_vision and not mmproj_path:
-                mmproj_path = self._download_mmproj(
+                n_ctx = n_ctx,
+                cache_type_kv = cache_type_kv,
+                speculative_type = speculative_type,
+                chat_template_override = chat_template_override,
+                extra_args = extra_args,
+                is_vision = is_vision,
+            ):
+                logger.info(
+                    f"load_model: backend already in target state for "
+                    f"'{model_identifier}', skipping reload"
+                )
+                return True
+
+            self._cancel_event.clear()
+
+            # ── Phase 1: kill old process (under lock, fast) ──────────
+            with self._lock:
+                self._kill_process()
+
+            binary = self._find_llama_server_binary()
+            if not binary:
+                raise RuntimeError(
+                    "llama-server binary not found. "
+                    "Run setup.sh to build it, install llama.cpp, "
+                    "or set LLAMA_SERVER_PATH environment variable."
+                )
+
+            # ── Phase 2: download (NO lock held, so cancel can proceed) ──
+            if hf_repo:
+                model_path = self._download_gguf(
                     hf_repo = hf_repo,
+                    hf_variant = hf_variant,
                     hf_token = hf_token,
                 )
-        elif gguf_path:
-            if not Path(gguf_path).is_file():
-                raise FileNotFoundError(f"GGUF file not found: {gguf_path}")
-            model_path = gguf_path
-        else:
-            raise ValueError("Either gguf_path or hf_repo must be provided")
+                # Auto-download mmproj for vision models
+                if is_vision and not mmproj_path:
+                    mmproj_path = self._download_mmproj(
+                        hf_repo = hf_repo,
+                        hf_token = hf_token,
+                    )
+            elif gguf_path:
+                if not Path(gguf_path).is_file():
+                    raise FileNotFoundError(f"GGUF file not found: {gguf_path}")
+                model_path = gguf_path
+            else:
+                raise ValueError("Either gguf_path or hf_repo must be provided")
 
-        # Set identifier early so _read_gguf_metadata can use it for DeepSeek detection
-        self._model_identifier = model_identifier
+            # Set identifier early so _read_gguf_metadata can use it for DeepSeek detection
+            self._model_identifier = model_identifier
 
-        # Read GGUF metadata (context_length, chat_template) -- fast, header only
-        self._read_gguf_metadata(model_path)
+            # Read GGUF metadata (context_length, chat_template) -- fast, header only
+            self._read_gguf_metadata(model_path)
 
-        # Check cancel after download
-        if self._cancel_event.is_set():
-            logger.info("Load cancelled after download phase")
-            return False
-
-        # ── Phase 3: start llama-server (under lock) ──────────────
-        with self._lock:
-            # Re-check cancel inside lock
+            # Check cancel after download
             if self._cancel_event.is_set():
-                logger.info("Load cancelled before server start")
+                logger.info("Load cancelled after download phase")
                 return False
 
-            self._port = self._find_free_port()
+            # ── Phase 3: start llama-server (under lock) ──────────────
+            with self._lock:
+                # Re-check cancel inside lock
+                if self._cancel_event.is_set():
+                    logger.info("Load cancelled before server start")
+                    return False
 
-            # Select GPU(s) based on model size + estimated KV cache.
-            # Seed safe defaults before GPU probing so the except path
-            # still has valid state to publish.
-            effective_ctx = n_ctx if n_ctx > 0 else (self._context_length or 0)
-            max_available_ctx = self._context_length or effective_ctx
-            gpus: list[tuple[int, int]] = []
-            try:
-                model_size = self._get_gguf_size_bytes(model_path)
-                gpus = self._get_gpu_free_memory()
+                self._port = self._find_free_port()
 
-                # Resolve effective context: 0 means let llama-server use the
-                # model's native length.  Only expand to a known native length
-                # if metadata is available; otherwise preserve 0 as a sentinel.
-                if n_ctx > 0:
-                    effective_ctx = n_ctx
-                elif self._context_length is not None:
-                    effective_ctx = self._context_length
-                else:
-                    effective_ctx = 0
-                original_ctx = effective_ctx
-                # Default UI ceiling to the model's native context length.
-                # GPU/VRAM-fit logic below may shrink this if hardware is limited.
+                # Select GPU(s) based on model size + estimated KV cache.
+                # Seed safe defaults before GPU probing so the except path
+                # still has valid state to publish.
+                effective_ctx = n_ctx if n_ctx > 0 else (self._context_length or 0)
                 max_available_ctx = self._context_length or effective_ctx
+                gpus: list[tuple[int, int]] = []
+                try:
+                    model_size = self._get_gguf_size_bytes(model_path)
+                    gpus = self._get_gpu_free_memory()
 
-                # Auto-cap context to fit in GPU VRAM and select GPUs.
-                #
-                # Two policies depending on whether the user set n_ctx:
-                #
-                # Explicit n_ctx (user chose a context length):
-                #   Honor it. Try the full requested context with _select_gpus
-                #   (which uses as many GPUs as needed). Only cap if it doesn't
-                #   fit on any GPU combination.
-                #
-                # Auto n_ctx=0 (model's native context):
-                #   Prefer fewer GPUs with reduced context over more GPUs,
-                #   since multi-GPU is slower and the user didn't ask for a
-                #   specific context length.
-                gpu_indices, use_fit = None, True
-                explicit_ctx = n_ctx > 0
+                    # Resolve effective context: 0 means let llama-server use the
+                    # model's native length.  Only expand to a known native length
+                    # if metadata is available; otherwise preserve 0 as a sentinel.
+                    if n_ctx > 0:
+                        effective_ctx = n_ctx
+                    elif self._context_length is not None:
+                        effective_ctx = self._context_length
+                    else:
+                        effective_ctx = 0
+                    original_ctx = effective_ctx
+                    # Default UI ceiling to the model's native context length.
+                    # GPU/VRAM-fit logic below may shrink this if hardware is limited.
+                    max_available_ctx = self._context_length or effective_ctx
 
-                if gpus and self._can_estimate_kv() and effective_ctx > 0:
-                    # Compute the largest hardware-aware cap from the model's
-                    # native context across all usable GPU subsets (for UI
-                    # bounds), independent of the currently requested context.
-                    native_ctx_for_cap = self._context_length or effective_ctx
-                    if native_ctx_for_cap > 0:
-                        ranked_for_cap = sorted(gpus, key = lambda g: g[1], reverse = True)
-                        best_cap = 0
-                        for n_gpus in range(1, len(ranked_for_cap) + 1):
-                            subset = ranked_for_cap[:n_gpus]
-                            pool_mib = sum(free for _, free in subset)
-                            capped = self._fit_context_to_vram(
-                                native_ctx_for_cap,
-                                pool_mib,
-                                model_size,
-                                cache_type_kv,
-                                n_parallel = n_parallel,
+                    # Auto-cap context to fit in GPU VRAM and select GPUs.
+                    #
+                    # Two policies depending on whether the user set n_ctx:
+                    #
+                    # Explicit n_ctx (user chose a context length):
+                    #   Honor it. Try the full requested context with _select_gpus
+                    #   (which uses as many GPUs as needed). Only cap if it doesn't
+                    #   fit on any GPU combination.
+                    #
+                    # Auto n_ctx=0 (model's native context):
+                    #   Prefer fewer GPUs with reduced context over more GPUs,
+                    #   since multi-GPU is slower and the user didn't ask for a
+                    #   specific context length.
+                    gpu_indices, use_fit = None, True
+                    explicit_ctx = n_ctx > 0
+
+                    if gpus and self._can_estimate_kv() and effective_ctx > 0:
+                        # Compute the largest hardware-aware cap from the model's
+                        # native context across all usable GPU subsets (for UI
+                        # bounds), independent of the currently requested context.
+                        native_ctx_for_cap = self._context_length or effective_ctx
+                        if native_ctx_for_cap > 0:
+                            ranked_for_cap = sorted(
+                                gpus, key = lambda g: g[1], reverse = True
                             )
-                            kv = self._estimate_kv_cache_bytes(
-                                capped, cache_type_kv, n_parallel = n_parallel
+                            best_cap = 0
+                            for n_gpus in range(1, len(ranked_for_cap) + 1):
+                                subset = ranked_for_cap[:n_gpus]
+                                pool_mib = sum(free for _, free in subset)
+                                capped = self._fit_context_to_vram(
+                                    native_ctx_for_cap,
+                                    pool_mib,
+                                    model_size,
+                                    cache_type_kv,
+                                    n_parallel = n_parallel,
+                                )
+                                kv = self._estimate_kv_cache_bytes(
+                                    capped, cache_type_kv, n_parallel = n_parallel
+                                )
+                                total_mib = (model_size + kv) / (1024 * 1024)
+                                if total_mib <= pool_mib * 0.90:
+                                    best_cap = max(best_cap, capped)
+                            if best_cap > 0:
+                                max_available_ctx = best_cap
+                            else:
+                                # Weights exceed 90% of every GPU subset's free
+                                # memory, so there is no fitting context. Anchor
+                                # the UI's "safe zone" threshold at 4096 (the
+                                # spec's default when the model cannot fit) so
+                                # the ctx slider shows the "might be slower"
+                                # warning as soon as the user drags above the
+                                # fallback default instead of never.
+                                max_available_ctx = min(4096, native_ctx_for_cap)
+
+                        if explicit_ctx:
+                            # Honor the user's requested context verbatim. If it
+                            # fits, pin GPUs and skip --fit; if it doesn't, ship
+                            # -c  --fit on and let llama-server flex
+                            # -ngl (CPU layer offload). The UI is expected to
+                            # have surfaced the "might be slower" warning before
+                            # the user submitted a ctx above the fit ceiling.
+                            requested_total = (
+                                model_size
+                                + self._estimate_kv_cache_bytes(
+                                    effective_ctx, cache_type_kv, n_parallel = n_parallel
+                                )
                             )
-                            total_mib = (model_size + kv) / (1024 * 1024)
-                            if total_mib <= pool_mib * 0.90:
-                                best_cap = max(best_cap, capped)
-                        if best_cap > 0:
-                            max_available_ctx = best_cap
+                            gpu_indices, use_fit = self._select_gpus(
+                                requested_total, gpus
+                            )
+                            # No silent shrink: effective_ctx stays == n_ctx.
                         else:
-                            # Weights exceed 90% of every GPU subset's free
-                            # memory, so there is no fitting context. Anchor
-                            # the UI's "safe zone" threshold at 4096 (the
-                            # spec's default when the model cannot fit) so
-                            # the ctx slider shows the "might be slower"
-                            # warning as soon as the user drags above the
-                            # fallback default instead of never.
-                            max_available_ctx = min(4096, native_ctx_for_cap)
+                            # Auto context: prefer fewer GPUs, cap context
+                            # to fit. Same headroom threshold as
+                            # _select_gpus (#5106).
+                            ranked = sorted(gpus, key = lambda g: g[1], reverse = True)
+                            pin_fraction = self._GPU_PIN_VRAM_FRACTION
+                            for n_gpus in range(1, len(ranked) + 1):
+                                subset = ranked[:n_gpus]
+                                pool_mib = sum(free for _, free in subset)
+                                capped = self._fit_context_to_vram(
+                                    effective_ctx,
+                                    pool_mib,
+                                    model_size,
+                                    cache_type_kv,
+                                    n_parallel = n_parallel,
+                                )
+                                kv = self._estimate_kv_cache_bytes(
+                                    capped, cache_type_kv, n_parallel = n_parallel
+                                )
+                                total_mib = (model_size + kv) / (1024 * 1024)
+                                if total_mib <= pool_mib * pin_fraction:
+                                    effective_ctx = capped
+                                    gpu_indices = sorted(idx for idx, _ in subset)
+                                    use_fit = False
+                                    break
+                            else:
+                                # Native ctx doesn't fit. Drop to 4096 and
+                                # re-check before deferring to --fit on:
+                                # a model that overflows at 131k may pin
+                                # comfortably with a 4096 KV cache (#5106).
+                                effective_ctx = min(4096, effective_ctx)
+                                if effective_ctx > 0:
+                                    for n_gpus in range(1, len(ranked) + 1):
+                                        subset = ranked[:n_gpus]
+                                        pool_mib = sum(free for _, free in subset)
+                                        kv = self._estimate_kv_cache_bytes(
+                                            effective_ctx,
+                                            cache_type_kv,
+                                            n_parallel = n_parallel,
+                                        )
+                                        total_mib = (model_size + kv) / (1024 * 1024)
+                                        if total_mib <= pool_mib * pin_fraction:
+                                            gpu_indices = sorted(
+                                                idx for idx, _ in subset
+                                            )
+                                            use_fit = False
+                                            break
 
-                    if explicit_ctx:
-                        # Honor the user's requested context verbatim. If it
-                        # fits, pin GPUs and skip --fit; if it doesn't, ship
-                        # -c  --fit on and let llama-server flex
-                        # -ngl (CPU layer offload). The UI is expected to
-                        # have surfaced the "might be slower" warning before
-                        # the user submitted a ctx above the fit ceiling.
-                        requested_total = model_size + self._estimate_kv_cache_bytes(
+                    elif gpus:
+                        # Can't estimate KV -- fall back to file-size-only check.
+                        # Without KV estimation we cannot prove a hardware cap, so
+                        # keep the ceiling at the native context (already the default).
+                        logger.debug(
+                            "Falling back to file-size-only GPU selection",
+                            model_size_gb = round(model_size / (1024**3), 2),
+                        )
+                        gpu_indices, use_fit = self._select_gpus(model_size, gpus)
+                        if use_fit and not explicit_ctx:
+                            # Weights don't fit on any subset. Default the UI to
+                            # 4096 so the slider doesn't land on an unusable native
+                            # context. --fit on will flex -ngl at runtime.
+                            effective_ctx = (
+                                min(4096, effective_ctx) if effective_ctx > 0 else 4096
+                            )
+
+                    if effective_ctx < original_ctx:
+                        kv_est = self._estimate_kv_cache_bytes(
                             effective_ctx, cache_type_kv, n_parallel = n_parallel
                         )
-                        gpu_indices, use_fit = self._select_gpus(requested_total, gpus)
-                        # No silent shrink: effective_ctx stays == n_ctx.
-                    else:
-                        # Auto context: prefer fewer GPUs, cap context
-                        # to fit. Same headroom threshold as
-                        # _select_gpus (#5106).
-                        ranked = sorted(gpus, key = lambda g: g[1], reverse = True)
-                        pin_fraction = self._GPU_PIN_VRAM_FRACTION
-                        for n_gpus in range(1, len(ranked) + 1):
-                            subset = ranked[:n_gpus]
-                            pool_mib = sum(free for _, free in subset)
-                            capped = self._fit_context_to_vram(
-                                effective_ctx,
-                                pool_mib,
-                                model_size,
-                                cache_type_kv,
-                                n_parallel = n_parallel,
-                            )
-                            kv = self._estimate_kv_cache_bytes(
-                                capped, cache_type_kv, n_parallel = n_parallel
-                            )
-                            total_mib = (model_size + kv) / (1024 * 1024)
-                            if total_mib <= pool_mib * pin_fraction:
-                                effective_ctx = capped
-                                gpu_indices = sorted(idx for idx, _ in subset)
-                                use_fit = False
-                                break
-                        else:
-                            # Native ctx doesn't fit. Drop to 4096 and
-                            # re-check before deferring to --fit on:
-                            # a model that overflows at 131k may pin
-                            # comfortably with a 4096 KV cache (#5106).
-                            effective_ctx = min(4096, effective_ctx)
-                            if effective_ctx > 0:
-                                for n_gpus in range(1, len(ranked) + 1):
-                                    subset = ranked[:n_gpus]
-                                    pool_mib = sum(free for _, free in subset)
-                                    kv = self._estimate_kv_cache_bytes(
-                                        effective_ctx,
-                                        cache_type_kv,
-                                        n_parallel = n_parallel,
-                                    )
-                                    total_mib = (model_size + kv) / (1024 * 1024)
-                                    if total_mib <= pool_mib * pin_fraction:
-                                        gpu_indices = sorted(idx for idx, _ in subset)
-                                        use_fit = False
-                                        break
-
-                elif gpus:
-                    # Can't estimate KV -- fall back to file-size-only check.
-                    # Without KV estimation we cannot prove a hardware cap, so
-                    # keep the ceiling at the native context (already the default).
-                    logger.debug(
-                        "Falling back to file-size-only GPU selection",
-                        model_size_gb = round(model_size / (1024**3), 2),
-                    )
-                    gpu_indices, use_fit = self._select_gpus(model_size, gpus)
-                    if use_fit and not explicit_ctx:
-                        # Weights don't fit on any subset. Default the UI to
-                        # 4096 so the slider doesn't land on an unusable native
-                        # context. --fit on will flex -ngl at runtime.
-                        effective_ctx = (
-                            min(4096, effective_ctx) if effective_ctx > 0 else 4096
+                        logger.info(
+                            f"Context auto-reduced: {original_ctx} -> {effective_ctx} "
+                            f"(model: {model_size / (1024**3):.1f} GB, "
+                            f"est. KV cache: {kv_est / (1024**3):.1f} GB)"
                         )
 
-                if effective_ctx < original_ctx:
-                    kv_est = self._estimate_kv_cache_bytes(
+                    kv_cache_bytes = self._estimate_kv_cache_bytes(
                         effective_ctx, cache_type_kv, n_parallel = n_parallel
                     )
                     logger.info(
-                        f"Context auto-reduced: {original_ctx} -> {effective_ctx} "
-                        f"(model: {model_size / (1024**3):.1f} GB, "
-                        f"est. KV cache: {kv_est / (1024**3):.1f} GB)"
+                        f"GGUF size: {model_size / (1024**3):.1f} GB, "
+                        f"est. KV cache: {kv_cache_bytes / (1024**3):.1f} GB, "
+                        f"context: {effective_ctx}, "
+                        f"GPUs free: {gpus}, selected: {gpu_indices}, fit: {use_fit}"
                     )
+                except Exception as e:
+                    logger.warning(f"GPU selection failed ({e}), using --fit on")
+                    gpu_indices, use_fit = None, True
+                    effective_ctx = n_ctx  # fall back to original
 
-                kv_cache_bytes = self._estimate_kv_cache_bytes(
-                    effective_ctx, cache_type_kv, n_parallel = n_parallel
-                )
-                logger.info(
-                    f"GGUF size: {model_size / (1024**3):.1f} GB, "
-                    f"est. KV cache: {kv_cache_bytes / (1024**3):.1f} GB, "
-                    f"context: {effective_ctx}, "
-                    f"GPUs free: {gpus}, selected: {gpu_indices}, fit: {use_fit}"
-                )
-            except Exception as e:
-                logger.warning(f"GPU selection failed ({e}), using --fit on")
-                gpu_indices, use_fit = None, True
-                effective_ctx = n_ctx  # fall back to original
+                cmd = [
+                    binary,
+                    "-m",
+                    model_path,
+                    "--port",
+                    str(self._port),
+                    "-c",
+                    str(effective_ctx) if effective_ctx > 0 else "0",
+                    "--parallel",
+                    str(n_parallel),
+                    "--flash-attn",
+                    "on",  # Force flash attention for speed
+                    # Error out at n_ctx instead of silently rotating the KV cache; frontend catches it and points the user at "Context Length".
+                    "--no-context-shift",
+                ]
 
-            cmd = [
-                binary,
-                "-m",
-                model_path,
-                "--port",
-                str(self._port),
-                "-c",
-                str(effective_ctx) if effective_ctx > 0 else "0",
-                "--parallel",
-                str(n_parallel),
-                "--flash-attn",
-                "on",  # Force flash attention for speed
-                # Error out at n_ctx instead of silently rotating the KV cache; frontend catches it and points the user at "Context Length".
-                "--no-context-shift",
-            ]
+                if use_fit:
+                    cmd.extend(["--fit", "on"])
+                elif gpu_indices is not None:
+                    # Model fits on selected GPU(s) -- offload all layers
+                    cmd.extend(["-ngl", "-1"])
 
-            if use_fit:
-                cmd.extend(["--fit", "on"])
-            elif gpu_indices is not None:
-                # Model fits on selected GPU(s) -- offload all layers
-                cmd.extend(["-ngl", "-1"])
-
-            # -1 = llama.cpp auto-detect (physical cores). Pass explicitly so we
-            # do not inherit llama-server's internal default, which has historically
-            # varied (hardware concurrency incl. hyperthreads on some builds).
-            cmd.extend(["--threads", str(n_threads if n_threads is not None else -1)])
-
-            # Always enable Jinja chat template rendering for proper template support
-            cmd.extend(["--jinja"])
-
-            # KV cache data type
-            _valid_cache_types = {
-                "f16",
-                "bf16",
-                "q8_0",
-                "q4_0",
-                "q4_1",
-                "q5_0",
-                "q5_1",
-                "iq4_nl",
-                "f32",
-            }
-            if cache_type_kv and cache_type_kv in _valid_cache_types:
+                # -1 = llama.cpp auto-detect (physical cores). Pass explicitly so we
+                # do not inherit llama-server's internal default, which has historically
+                # varied (hardware concurrency incl. hyperthreads on some builds).
                 cmd.extend(
-                    ["--cache-type-k", cache_type_kv, "--cache-type-v", cache_type_kv]
+                    ["--threads", str(n_threads if n_threads is not None else -1)]
                 )
-                self._cache_type_kv = cache_type_kv
-                logger.info(f"KV cache type: {cache_type_kv}")
-            else:
-                self._cache_type_kv = None
 
-            # Speculative decoding (n-gram self-speculation, zero VRAM cost)
-            # ngram-mod: ~16 MB shared hash pool, constant memory/complexity,
-            # variable draft lengths.  Helps most when the model repeats
-            # existing text (code refactoring, summarization, reasoning).
-            # For general chat with low repetition, overhead is ~5 ms.
-            #
-            # Benchmarks from upstream llama.cpp speculative-decoding PRs:
-            #   Scenario                        | Without | With    | Speedup
-            #   gpt-oss-120b code refactor      | 181 t/s | 446 t/s | 2.5x
-            #   Qwen3-235B offloaded            |  12 t/s |  21 t/s | 1.8x
-            #   gpt-oss-120b repeat (92% accept)| 181 t/s | 814 t/s | 4.5x
-            #
-            # Params from llama.cpp docs (docs/speculative.md):
-            #   --spec-ngram-size-n 24  (small n not recommended)
-            #   --draft-min 48 --draft-max 64 (MoEs need long drafts;
-            #     dense models can reduce these)
-            # ref: https://github.com/ggml-org/llama.cpp/blob/master/docs/speculative.md
-            # ref: https://github.com/ggml-org/llama.cpp/pull/19164
-            # ref: https://github.com/ggml-org/llama.cpp/pull/18471
-            # ``"default"`` -> let llama-server pick a sensible spec
-            # config via ``--spec-default``. Explicit type names are
-            # passed through with the manual draft tuning we've shipped
-            # historically so power users keep their overrides.
-            _valid_spec_types = {"ngram-simple", "ngram-mod"}
-            normalized_spec = (
-                speculative_type.lower().strip() if speculative_type else None
-            )
-            if normalized_spec and normalized_spec != "off" and not is_vision:
-                if normalized_spec == "default":
-                    cmd.append("--spec-default")
-                    self._speculative_type = "default"
-                elif normalized_spec in _valid_spec_types:
-                    cmd.extend(["--spec-type", normalized_spec])
-                    if normalized_spec == "ngram-mod":
-                        cmd.extend(
-                            [
-                                "--spec-ngram-size-n",
-                                "24",
-                                "--draft-min",
-                                "48",
-                                "--draft-max",
-                                "64",
-                            ]
-                        )
-                    self._speculative_type = normalized_spec
+                # Always enable Jinja chat template rendering for proper template support
+                cmd.extend(["--jinja"])
+
+                # KV cache data type
+                _valid_cache_types = {
+                    "f16",
+                    "bf16",
+                    "q8_0",
+                    "q4_0",
+                    "q4_1",
+                    "q5_0",
+                    "q5_1",
+                    "iq4_nl",
+                    "f32",
+                }
+                if cache_type_kv and cache_type_kv in _valid_cache_types:
+                    cmd.extend(
+                        [
+                            "--cache-type-k",
+                            cache_type_kv,
+                            "--cache-type-v",
+                            cache_type_kv,
+                        ]
+                    )
+                    self._cache_type_kv = cache_type_kv
+                    logger.info(f"KV cache type: {cache_type_kv}")
+                else:
+                    self._cache_type_kv = None
+
+                # Speculative decoding (n-gram self-speculation, zero VRAM cost)
+                # ngram-mod: ~16 MB shared hash pool, constant memory/complexity,
+                # variable draft lengths.  Helps most when the model repeats
+                # existing text (code refactoring, summarization, reasoning).
+                # For general chat with low repetition, overhead is ~5 ms.
+                #
+                # Benchmarks from upstream llama.cpp speculative-decoding PRs:
+                #   Scenario                        | Without | With    | Speedup
+                #   gpt-oss-120b code refactor      | 181 t/s | 446 t/s | 2.5x
+                #   Qwen3-235B offloaded            |  12 t/s |  21 t/s | 1.8x
+                #   gpt-oss-120b repeat (92% accept)| 181 t/s | 814 t/s | 4.5x
+                #
+                # Params from llama.cpp docs (docs/speculative.md):
+                #   --spec-ngram-size-n 24  (small n not recommended)
+                #   --draft-min 48 --draft-max 64 (MoEs need long drafts;
+                #     dense models can reduce these)
+                # ref: https://github.com/ggml-org/llama.cpp/blob/master/docs/speculative.md
+                # ref: https://github.com/ggml-org/llama.cpp/pull/19164
+                # ref: https://github.com/ggml-org/llama.cpp/pull/18471
+                # ``"default"`` -> let llama-server pick a sensible spec
+                # config via ``--spec-default``. Explicit type names are
+                # passed through with the manual draft tuning we've shipped
+                # historically so power users keep their overrides.
+                _valid_spec_types = {"ngram-simple", "ngram-mod"}
+                normalized_spec = (
+                    speculative_type.lower().strip() if speculative_type else None
+                )
+                if normalized_spec and normalized_spec != "off" and not is_vision:
+                    if normalized_spec == "default":
+                        cmd.append("--spec-default")
+                        self._speculative_type = "default"
+                    elif normalized_spec in _valid_spec_types:
+                        cmd.extend(["--spec-type", normalized_spec])
+                        if normalized_spec == "ngram-mod":
+                            cmd.extend(
+                                [
+                                    "--spec-ngram-size-n",
+                                    "24",
+                                    "--draft-min",
+                                    "48",
+                                    "--draft-max",
+                                    "64",
+                                ]
+                            )
+                        self._speculative_type = normalized_spec
+                    else:
+                        self._speculative_type = None
                 else:
                     self._speculative_type = None
-            else:
-                self._speculative_type = None
 
-            # Apply custom chat template override if provided
-            self._chat_template_override = chat_template_override
-            if chat_template_override:
-                import tempfile
+                # Apply custom chat template override if provided
+                self._chat_template_override = chat_template_override
+                if chat_template_override:
+                    import tempfile
 
-                flags = detect_reasoning_flags(
-                    chat_template_override,
-                    self._model_identifier,
-                    log_source = "GGUF chat template override",
-                )
-                self._supports_reasoning = flags["supports_reasoning"]
-                self._reasoning_style = flags["reasoning_style"]
-                self._reasoning_always_on = flags["reasoning_always_on"]
-                self._supports_preserve_thinking = flags["supports_preserve_thinking"]
-                self._supports_tools = flags["supports_tools"]
-
-                self._chat_template_file = tempfile.NamedTemporaryFile(
-                    mode = "w",
-                    suffix = ".jinja",
-                    delete = False,
-                    prefix = "unsloth_chat_template_",
-                )
-                self._chat_template_file.write(chat_template_override)
-                self._chat_template_file.close()
-                cmd.extend(["--chat-template-file", self._chat_template_file.name])
-                logger.info(
-                    f"Using custom chat template file: {self._chat_template_file.name}"
-                )
-
-            # For reasoning models, set default thinking mode.
-            # Qwen3.5/3.6 models below 9B (0.8B, 2B, 4B) disable thinking by default.
-            # Only 9B and larger enable thinking.
-            # Always-on templates ignore the kwarg entirely, so skip.
-            if self._supports_reasoning and not self._reasoning_always_on:
-                thinking_default = True
-                mid = (model_identifier or "").lower()
-                if "qwen3.5" in mid or "qwen3.6" in mid:
-                    size_val = _extract_model_size_b(mid)
-                    if size_val is not None and size_val < 9:
-                        thinking_default = False
-                self._reasoning_default = thinking_default
-                reasoning_kw = self._reasoning_kwargs(thinking_default)
-                cmd.extend(
-                    [
-                        "--chat-template-kwargs",
-                        json.dumps(reasoning_kw),
+                    flags = detect_reasoning_flags(
+                        chat_template_override,
+                        self._model_identifier,
+                        log_source = "GGUF chat template override",
+                    )
+                    self._supports_reasoning = flags["supports_reasoning"]
+                    self._reasoning_style = flags["reasoning_style"]
+                    self._reasoning_always_on = flags["reasoning_always_on"]
+                    self._supports_preserve_thinking = flags[
+                        "supports_preserve_thinking"
                     ]
-                )
-                logger.info(f"Reasoning model: {reasoning_kw} by default")
+                    self._supports_tools = flags["supports_tools"]
 
-            if mmproj_path:
-                if not Path(mmproj_path).is_file():
-                    logger.warning(f"mmproj file not found: {mmproj_path}")
-                else:
-                    cmd.extend(["--mmproj", mmproj_path])
-                    logger.info(f"Using mmproj for vision: {mmproj_path}")
-
-            # Option C: add --api-key for direct client access when enabled
-            import os as _os
-            import secrets as _secrets
-
-            if _os.getenv("UNSLOTH_DIRECT_STREAM", "0") == "1":
-                self._api_key = _secrets.token_urlsafe(32)
-                cmd.extend(["--api-key", self._api_key])
-                logger.info("llama-server started with --api-key for direct streaming")
-            else:
-                self._api_key = None
-
-            # User-supplied pass-through args go last so llama.cpp's
-            # last-wins flag parsing lets the user override Studio's
-            # auto-set tier-2 flags (e.g. --cache-type-k, --spec-type).
-            # The route layer has already validated this list against
-            # the managed-flag denylist via validate_extra_args().
-            if extra_args:
-                cmd.extend(str(a) for a in extra_args)
-                logger.info(
-                    f"Appending user extra args to llama-server: {list(extra_args)}"
-                )
-
-            _log_cmd = list(cmd)
-            if "--api-key" in _log_cmd:
-                _ki = _log_cmd.index("--api-key") + 1
-                if _ki < len(_log_cmd):
-                    _log_cmd[_ki] = ""
-            logger.info(f"Starting llama-server: {' '.join(_log_cmd)}")
-
-            # Set library paths so llama-server can find its shared libs and CUDA DLLs
-            import os
-            import sys
-
-            env = child_env_without_native_path_secret()
-            binary_dir = str(Path(binary).parent)
-
-            if sys.platform == "win32":
-                # CUDA DLLs (cudart64_X.dll, cublas64_X.dll, etc.) must
-                # be on PATH. See _build_windows_path_dirs for the
-                # ordering rationale (#5106).
-                path_dirs = self._build_windows_path_dirs(
-                    binary_dir,
-                    sys.prefix,
-                    os.environ.get("CUDA_PATH", ""),
-                )
-                existing_path = env.get("PATH", "")
-                env["PATH"] = ";".join(path_dirs) + ";" + existing_path
-            else:
-                # Linux: set LD_LIBRARY_PATH for shared libs next to the binary
-                # and CUDA runtime libs (libcudart, libcublas, etc.)
-                import platform
-
-                lib_dirs = [binary_dir]
-                _arch = platform.machine()  # x86_64, aarch64, etc.
-
-                # Pip-installed nvidia CUDA runtime libs (e.g. torch's
-                # bundled cuda-bindings).  The prebuilt llama.cpp binary
-                # links against libcudart.so.13 / libcublas.so.13 which
-                # live here, not in /usr/local/cuda.
-                import glob as _glob
-
-                for _nv_pattern in [
-                    os.path.join(
-                        sys.prefix,
-                        "lib",
-                        "python*",
-                        "site-packages",
-                        "nvidia",
-                        "cu*",
-                        "lib",
-                    ),
-                    os.path.join(
-                        sys.prefix,
-                        "lib",
-                        "python*",
-                        "site-packages",
-                        "nvidia",
-                        "cudnn",
-                        "lib",
-                    ),
-                    os.path.join(
-                        sys.prefix,
-                        "lib",
-                        "python*",
-                        "site-packages",
-                        "nvidia",
-                        "nvjitlink",
-                        "lib",
-                    ),
-                ]:
-                    for _nv_dir in _glob.glob(_nv_pattern):
-                        if os.path.isdir(_nv_dir):
-                            lib_dirs.append(_nv_dir)
-
-                for cuda_lib in [
-                    "/usr/local/cuda/lib64",
-                    f"/usr/local/cuda/targets/{_arch}-linux/lib",
-                    # Fallback CUDA compat paths (e.g. binary built with
-                    # CUDA 12 on a system where default /usr/local/cuda
-                    # points to CUDA 13+).
-                    "/usr/local/cuda-12/lib64",
-                    "/usr/local/cuda-12.8/lib64",
-                    f"/usr/local/cuda-12/targets/{_arch}-linux/lib",
-                    f"/usr/local/cuda-12.8/targets/{_arch}-linux/lib",
-                ]:
-                    if os.path.isdir(cuda_lib):
-                        lib_dirs.append(cuda_lib)
-                existing_ld = env.get("LD_LIBRARY_PATH", "")
-                new_ld = ":".join(lib_dirs)
-                env["LD_LIBRARY_PATH"] = (
-                    f"{new_ld}:{existing_ld}" if existing_ld else new_ld
-                )
-
-            # Pin to selected GPU(s). On ROCm, llama-server (and any torch
-            # in the subprocess) honors HIP_VISIBLE_DEVICES / ROCR_VISIBLE_DEVICES;
-            # narrowing only CUDA_VISIBLE_DEVICES leaves an AMD child seeing
-            # the full HIP/ROCR set the parent inherited.
-            if gpu_indices is not None:
-                pinned = ",".join(str(i) for i in gpu_indices)
-                env["CUDA_VISIBLE_DEVICES"] = pinned
-                try:
-                    import torch as _torch
-
-                    if getattr(_torch.version, "hip", None) is not None:
-                        env["HIP_VISIBLE_DEVICES"] = pinned
-                        env["ROCR_VISIBLE_DEVICES"] = pinned
-                except Exception as e:
-                    logger.debug(
-                        "Failed to set ROCm visibility env vars for child: %s", e
+                    self._chat_template_file = tempfile.NamedTemporaryFile(
+                        mode = "w",
+                        suffix = ".jinja",
+                        delete = False,
+                        prefix = "unsloth_chat_template_",
+                    )
+                    self._chat_template_file.write(chat_template_override)
+                    self._chat_template_file.close()
+                    cmd.extend(["--chat-template-file", self._chat_template_file.name])
+                    logger.info(
+                        f"Using custom chat template file: {self._chat_template_file.name}"
                     )
 
-            # Defensive kill: if a concurrent load slipped past Phase 1
-            # (because its `self._process` was None at the time) and
-            # already stored a Popen handle here, drop that orphan
-            # before we overwrite the reference. See issue #5161.
-            self._kill_process()
-
-            self._stdout_lines = []
-            self._process = subprocess.Popen(
-                cmd,
-                stdout = subprocess.PIPE,
-                stderr = subprocess.STDOUT,
-                text = True,
-                env = env,
-                **_windows_hidden_subprocess_kwargs(),
-            )
-
-            # Start background thread to drain stdout and prevent pipe deadlock
-            self._stdout_thread = threading.Thread(
-                target = self._drain_stdout, daemon = True, name = "llama-stdout"
-            )
-            self._stdout_thread.start()
-
-            # Store the resolved on-disk path, not the caller's kwarg. In
-            # HF mode the caller passes gguf_path=None and the real path
-            # (``model_path``) is what llama-server is actually mmap'ing.
-            # Downstream consumers (load_progress, log lines, etc.) need
-            # the path that exists on disk.
-            self._gguf_path = model_path
-            self._hf_repo = hf_repo
-            # For local GGUF files, extract variant from filename if not provided
-            if hf_variant:
-                self._hf_variant = hf_variant
-            elif gguf_path:
-                try:
-                    from utils.models.model_config import _extract_quant_label
-
-                    self._hf_variant = _extract_quant_label(gguf_path)
-                except Exception:
-                    self._hf_variant = None
-            else:
-                self._hf_variant = None
-            self._is_vision = is_vision
-            self._model_identifier = model_identifier
-
-            # Store the effective (possibly capped) context separately.
-            # Do NOT overwrite _context_length -- it holds the model's native
-            # context length from GGUF metadata and is used for display/info.
-            self._effective_context_length = (
-                effective_ctx if effective_ctx > 0 else self._context_length
-            )
-            self._max_context_length = (
-                max_available_ctx
-                if max_available_ctx > 0
-                else self._effective_context_length
-            )
-
-            # Wait for llama-server to become healthy
-            if not self._wait_for_health(timeout = 600.0):
-                self._kill_process()
-                _gguf = gguf_path or ""
-                _is_ollama = (
-                    ".studio_links" in _gguf
-                    or os.sep + "ollama_links" + os.sep in _gguf
-                    or os.sep + ".cache" + os.sep + "ollama" + os.sep in _gguf
-                    or (self._model_identifier or "").startswith("ollama/")
-                )
-                # Only show the Ollama-specific message when the server
-                # output indicates a GGUF compatibility issue, not for
-                # unrelated failures like OOM or missing binaries.
-                if _is_ollama:
-                    _output = "\n".join(self._stdout_lines[-50:]).lower()
-                    _gguf_compat_hints = (
-                        "key not found",
-                        "unknown model architecture",
-                        "failed to load model",
+                # For reasoning models, set default thinking mode.
+                # Qwen3.5/3.6 models below 9B (0.8B, 2B, 4B) disable thinking by default.
+                # Only 9B and larger enable thinking.
+                # Always-on templates ignore the kwarg entirely, so skip.
+                if self._supports_reasoning and not self._reasoning_always_on:
+                    thinking_default = True
+                    mid = (model_identifier or "").lower()
+                    if "qwen3.5" in mid or "qwen3.6" in mid:
+                        size_val = _extract_model_size_b(mid)
+                        if size_val is not None and size_val < 9:
+                            thinking_default = False
+                    self._reasoning_default = thinking_default
+                    reasoning_kw = self._reasoning_kwargs(thinking_default)
+                    cmd.extend(
+                        [
+                            "--chat-template-kwargs",
+                            json.dumps(reasoning_kw),
+                        ]
                     )
-                    if any(h in _output for h in _gguf_compat_hints):
-                        raise RuntimeError(
-                            "Some Ollama models do not work with llama.cpp. "
-                            "Try a different model, or use this model directly through Ollama instead."
+                    logger.info(f"Reasoning model: {reasoning_kw} by default")
+
+                if mmproj_path:
+                    if not Path(mmproj_path).is_file():
+                        logger.warning(f"mmproj file not found: {mmproj_path}")
+                    else:
+                        # #5347 guard for paths that bypass detect_mmproj_file.
+                        from utils.models.model_config import (
+                            mmproj_matches_model_family,
                         )
-                raise RuntimeError(
-                    "llama-server failed to start. "
-                    "Check that the GGUF file is valid and you have enough memory."
+
+                        if not mmproj_matches_model_family(model_path, mmproj_path):
+                            logger.warning(
+                                f"Skipping mmproj with mismatched family: "
+                                f"model={Path(model_path).name}, "
+                                f"mmproj={Path(mmproj_path).name}"
+                            )
+                        else:
+                            cmd.extend(["--mmproj", mmproj_path])
+                            logger.info(f"Using mmproj for vision: {mmproj_path}")
+
+                # Option C: add --api-key for direct client access when enabled
+                import os as _os
+                import secrets as _secrets
+
+                if _os.getenv("UNSLOTH_DIRECT_STREAM", "0") == "1":
+                    self._api_key = _secrets.token_urlsafe(32)
+                    cmd.extend(["--api-key", self._api_key])
+                    logger.info(
+                        "llama-server started with --api-key for direct streaming"
+                    )
+                else:
+                    self._api_key = None
+
+                # User-supplied pass-through args go last so llama.cpp's
+                # last-wins flag parsing lets the user override Studio's
+                # auto-set tier-2 flags (e.g. --cache-type-k, --spec-type).
+                # The route layer has already validated this list against
+                # the managed-flag denylist via validate_extra_args().
+                if extra_args:
+                    cmd.extend(str(a) for a in extra_args)
+                    logger.info(
+                        f"Appending user extra args to llama-server: {list(extra_args)}"
+                    )
+
+                _log_cmd = list(cmd)
+                if "--api-key" in _log_cmd:
+                    _ki = _log_cmd.index("--api-key") + 1
+                    if _ki < len(_log_cmd):
+                        _log_cmd[_ki] = ""
+                logger.info(f"Starting llama-server: {' '.join(_log_cmd)}")
+
+                # Set library paths so llama-server can find its shared libs and CUDA DLLs
+                import os
+                import sys
+
+                env = child_env_without_native_path_secret()
+                binary_dir = str(Path(binary).parent)
+
+                if sys.platform == "win32":
+                    # CUDA DLLs (cudart64_X.dll, cublas64_X.dll, etc.) must
+                    # be on PATH. See _build_windows_path_dirs for the
+                    # ordering rationale (#5106).
+                    path_dirs = self._build_windows_path_dirs(
+                        binary_dir,
+                        sys.prefix,
+                        os.environ.get("CUDA_PATH", ""),
+                    )
+                    existing_path = env.get("PATH", "")
+                    env["PATH"] = ";".join(path_dirs) + ";" + existing_path
+                else:
+                    # Linux: set LD_LIBRARY_PATH for shared libs next to the binary
+                    # and CUDA runtime libs (libcudart, libcublas, etc.)
+                    import platform
+
+                    lib_dirs = [binary_dir]
+                    _arch = platform.machine()  # x86_64, aarch64, etc.
+
+                    # Pip-installed nvidia CUDA runtime libs (e.g. torch's
+                    # bundled cuda-bindings).  The prebuilt llama.cpp binary
+                    # links against libcudart.so.13 / libcublas.so.13 which
+                    # live here, not in /usr/local/cuda.
+                    import glob as _glob
+
+                    for _nv_pattern in [
+                        os.path.join(
+                            sys.prefix,
+                            "lib",
+                            "python*",
+                            "site-packages",
+                            "nvidia",
+                            "cu*",
+                            "lib",
+                        ),
+                        os.path.join(
+                            sys.prefix,
+                            "lib",
+                            "python*",
+                            "site-packages",
+                            "nvidia",
+                            "cudnn",
+                            "lib",
+                        ),
+                        os.path.join(
+                            sys.prefix,
+                            "lib",
+                            "python*",
+                            "site-packages",
+                            "nvidia",
+                            "nvjitlink",
+                            "lib",
+                        ),
+                    ]:
+                        for _nv_dir in _glob.glob(_nv_pattern):
+                            if os.path.isdir(_nv_dir):
+                                lib_dirs.append(_nv_dir)
+
+                    for cuda_lib in [
+                        "/usr/local/cuda/lib64",
+                        f"/usr/local/cuda/targets/{_arch}-linux/lib",
+                        # Fallback CUDA compat paths (e.g. binary built with
+                        # CUDA 12 on a system where default /usr/local/cuda
+                        # points to CUDA 13+).
+                        "/usr/local/cuda-12/lib64",
+                        "/usr/local/cuda-12.8/lib64",
+                        f"/usr/local/cuda-12/targets/{_arch}-linux/lib",
+                        f"/usr/local/cuda-12.8/targets/{_arch}-linux/lib",
+                    ]:
+                        if os.path.isdir(cuda_lib):
+                            lib_dirs.append(cuda_lib)
+                    existing_ld = env.get("LD_LIBRARY_PATH", "")
+                    new_ld = ":".join(lib_dirs)
+                    env["LD_LIBRARY_PATH"] = (
+                        f"{new_ld}:{existing_ld}" if existing_ld else new_ld
+                    )
+
+                # Pin to selected GPU(s). On ROCm, llama-server (and any torch
+                # in the subprocess) honors HIP_VISIBLE_DEVICES / ROCR_VISIBLE_DEVICES;
+                # narrowing only CUDA_VISIBLE_DEVICES leaves an AMD child seeing
+                # the full HIP/ROCR set the parent inherited.
+                if gpu_indices is not None:
+                    pinned = ",".join(str(i) for i in gpu_indices)
+                    env["CUDA_VISIBLE_DEVICES"] = pinned
+                    try:
+                        import torch as _torch
+
+                        if getattr(_torch.version, "hip", None) is not None:
+                            env["HIP_VISIBLE_DEVICES"] = pinned
+                            env["ROCR_VISIBLE_DEVICES"] = pinned
+                    except Exception as e:
+                        logger.debug(
+                            "Failed to set ROCm visibility env vars for child: %s", e
+                        )
+
+                # Defensive kill: if a concurrent load slipped past Phase 1
+                # (because its `self._process` was None at the time) and
+                # already stored a Popen handle here, drop that orphan
+                # before we overwrite the reference. See issue #5161.
+                self._kill_process()
+
+                self._stdout_lines = []
+                self._process = subprocess.Popen(
+                    cmd,
+                    stdout = subprocess.PIPE,
+                    stderr = subprocess.STDOUT,
+                    text = True,
+                    env = env,
+                    **_windows_hidden_subprocess_kwargs(),
                 )
 
-            self._healthy = True
+                # Start background thread to drain stdout and prevent pipe deadlock
+                self._stdout_thread = threading.Thread(
+                    target = self._drain_stdout, daemon = True, name = "llama-stdout"
+                )
+                self._stdout_thread.start()
 
-            # Catch silent CPU fallback when GPU was intended (#5106).
-            self._gpu_offload_active = self._classify_gpu_offload(
-                gpu_indices is not None or use_fit, gpus or []
-            )
-            if self._gpu_offload_active is False:
-                logger.warning(
-                    "llama-server appears to have loaded the model entirely "
-                    "on CPU even though Studio detected at least one GPU. "
-                    "This usually means the prebuilt binary's GPU backend "
-                    "failed to load -- on Windows, cudart64_X.dll / "
-                    "cublas64_X.dll could not be resolved. Reinstall the "
-                    "Studio llama.cpp prebuilt or install a matching CUDA "
-                    "toolkit (issue unslothai/unsloth#5106).",
+                # Store the resolved on-disk path, not the caller's kwarg. In
+                # HF mode the caller passes gguf_path=None and the real path
+                # (``model_path``) is what llama-server is actually mmap'ing.
+                # Downstream consumers (load_progress, log lines, etc.) need
+                # the path that exists on disk.
+                self._gguf_path = model_path
+                self._hf_repo = hf_repo
+                # For local GGUF files, extract variant from filename if not provided
+                if hf_variant:
+                    self._hf_variant = hf_variant
+                elif gguf_path:
+                    try:
+                        from utils.models.model_config import _extract_quant_label
+
+                        self._hf_variant = _extract_quant_label(gguf_path)
+                    except Exception:
+                        self._hf_variant = None
+                else:
+                    self._hf_variant = None
+                self._is_vision = is_vision
+                self._model_identifier = model_identifier
+
+                # Store the effective (possibly capped) context separately.
+                # Do NOT overwrite _context_length -- it holds the model's native
+                # context length from GGUF metadata and is used for display/info.
+                self._effective_context_length = (
+                    effective_ctx if effective_ctx > 0 else self._context_length
+                )
+                self._max_context_length = (
+                    max_available_ctx
+                    if max_available_ctx > 0
+                    else self._effective_context_length
                 )
 
-            logger.info(
-                f"llama-server ready on port {self._port} "
-                f"for model '{model_identifier}'"
-            )
-            return True
+                # Wait for llama-server to become healthy
+                if not self._wait_for_health(timeout = 600.0):
+                    self._kill_process()
+                    _gguf = gguf_path or ""
+                    _is_ollama = (
+                        ".studio_links" in _gguf
+                        or os.sep + "ollama_links" + os.sep in _gguf
+                        or os.sep + ".cache" + os.sep + "ollama" + os.sep in _gguf
+                        or (self._model_identifier or "").startswith("ollama/")
+                    )
+                    # Only show the Ollama-specific message when the server
+                    # output indicates a GGUF compatibility issue, not for
+                    # unrelated failures like OOM or missing binaries.
+                    if _is_ollama:
+                        _output = "\n".join(self._stdout_lines[-50:]).lower()
+                        _gguf_compat_hints = (
+                            "key not found",
+                            "unknown model architecture",
+                            "failed to load model",
+                        )
+                        if any(h in _output for h in _gguf_compat_hints):
+                            raise RuntimeError(
+                                "Some Ollama models do not work with llama.cpp. "
+                                "Try a different model, or use this model directly through Ollama instead."
+                            )
+                    raise RuntimeError(
+                        "llama-server failed to start. "
+                        "Check that the GGUF file is valid and you have enough memory."
+                    )
+
+                self._healthy = True
+
+                # Commit caller intent only after _healthy=True so a
+                # failed startup can't poison the next inheritance check.
+                # None keeps prior, [] clears, list sets. Source records
+                # the caller's hf_variant (None for local files) so the
+                # route's same_source check stays symmetric.
+                if extra_args is not None:
+                    self._extra_args = list(extra_args)
+                    self._extra_args_source = (model_identifier, hf_variant)
+                self._requested_n_ctx = int(n_ctx)
+
+                # Catch silent CPU fallback when GPU was intended (#5106).
+                self._gpu_offload_active = self._classify_gpu_offload(
+                    gpu_indices is not None or use_fit, gpus or []
+                )
+                if self._gpu_offload_active is False:
+                    logger.warning(
+                        "llama-server appears to have loaded the model entirely "
+                        "on CPU even though Studio detected at least one GPU. "
+                        "This usually means the prebuilt binary's GPU backend "
+                        "failed to load -- on Windows, cudart64_X.dll / "
+                        "cublas64_X.dll could not be resolved. Reinstall the "
+                        "Studio llama.cpp prebuilt or install a matching CUDA "
+                        "toolkit (issue unslothai/unsloth#5106).",
+                    )
+
+                logger.info(
+                    f"llama-server ready on port {self._port} "
+                    f"for model '{model_identifier}'"
+                )
+                return True
+
+    def _already_in_target_state(
+        self,
+        *,
+        model_identifier: str,
+        hf_variant: Optional[str],
+        n_ctx: int,
+        cache_type_kv: Optional[str],
+        speculative_type: Optional[str],
+        chat_template_override: Optional[str],
+        extra_args: Optional[List[str]],
+        is_vision: bool,
+        gguf_path: Optional[str] = None,
+    ) -> bool:
+        """True iff the live server already satisfies these load kwargs.
+
+        Mirrors ``routes/inference.py:_request_matches_loaded_settings``
+        but compares raw kwargs so ``load_model`` can short-circuit a
+        duplicate /load that raced past the route-level check (#5401).
+        """
+        if not self.is_loaded:
+            return False
+        if (self._model_identifier or "").lower() != (model_identifier or "").lower():
+            return False
+        # Direct-file loads pass hf_variant=None while the backend
+        # stores an extracted filename label; compare paths instead
+        # to keep the guard symmetric.
+        if gguf_path is not None and self._gguf_path:
+            try:
+                if Path(self._gguf_path).resolve() != Path(gguf_path).resolve():
+                    return False
+            except OSError:
+                return False
+        elif (self._hf_variant or "").lower() != (hf_variant or "").lower():
+            return False
+        if self._requested_n_ctx != int(n_ctx):
+            return False
+
+        def _norm(value):
+            if value is None:
+                return None
+            if isinstance(value, str):
+                stripped = value.strip().lower()
+                return stripped or None
+            return value
+
+        if _norm(self._cache_type_kv) != _norm(cache_type_kv):
+            return False
+
+        # Vision GGUFs silently drop speculative decoding in
+        # load_model (the spec gate is "not is_vision"); treat the
+        # request's value as "off" so a vision load with
+        # speculative_type="default" still matches.
+        if self._is_vision or is_vision:
+            req_spec = "off"
+        else:
+            req_spec = _norm(speculative_type) or "off"
+        backend_spec = _norm(self._speculative_type) or "off"
+        if req_spec != backend_spec:
+            return False
+
+        if (self._chat_template_override or None) != (chat_template_override or None):
+            return False
+
+        # extra_args=None means "no opinion" (inherit semantics handled
+        # at the route layer); only an explicit list forces equality.
+        if extra_args is not None:
+            current = list(self._extra_args) if self._extra_args is not None else []
+            if list(extra_args) != current:
+                return False
+        return True
 
     def _classify_gpu_offload(
         self,
@@ -2741,6 +2908,10 @@ class LlamaCppBackend:
             logger.warning(f"Error killing llama-server process: {e}")
         finally:
             self._process = None
+            # Clear healthy so a /load arriving during the replacement
+            # server's warm-up window cannot short-circuit against the
+            # previous server's health (#5401).
+            self._healthy = False
             if self._stdout_thread is not None:
                 self._stdout_thread.join(timeout = 2)
                 self._stdout_thread = None
@@ -3763,7 +3934,7 @@ class LlamaCppBackend:
 
                                 except json.JSONDecodeError:
                                     logger.debug(
-                                        f"Skipping malformed SSE line: " f"{line[:100]}"
+                                        f"Skipping malformed SSE line: {line[:100]}"
                                     )
                             if _stream_done:
                                 break  # exit outer for
diff --git a/studio/backend/core/inference/llama_server_args.py b/studio/backend/core/inference/llama_server_args.py
index 44c7d542c7..0f6927fc5a 100644
--- a/studio/backend/core/inference/llama_server_args.py
+++ b/studio/backend/core/inference/llama_server_args.py
@@ -69,7 +69,15 @@ _DENYLIST_GROUPS: tuple[frozenset[str], ...] = (
     # Single-model server -- Studio runs one model per llama-server
     # process and serves its own UI. Enabling multi-model loading or
     # llama-server's built-in web UI changes the surface clients see.
+    # ``--webui``/``--no-webui`` are the legacy spelling; current
+    # upstream uses ``--ui``/``--no-ui`` + ``--ui-*`` companions.
+    # Keep both so the denylist matches old and new llama-server
+    # binaries (Studio's prebuilt vs system-llama.cpp).
     frozenset({"--webui", "--no-webui"}),
+    frozenset({"--ui", "--no-ui"}),
+    frozenset({"--ui-config"}),
+    frozenset({"--ui-config-file"}),
+    frozenset({"--ui-mcp-proxy", "--no-ui-mcp-proxy"}),
     frozenset({"--models-dir"}),
     frozenset({"--models-preset"}),
     frozenset({"--models-max"}),
@@ -118,3 +126,95 @@ def validate_extra_args(args: Optional[Iterable[str]]) -> list[str]:
 def is_managed_flag(flag: str) -> bool:
     """True if ``flag`` is a Studio-managed llama-server flag."""
     return flag in _DENYLIST
+
+
+# Pass-through flags that shadow first-class ``LoadRequest`` fields
+# (max_seq_length, cache_type_kv, speculative_type,
+# chat_template_override). Stripped from inherited extras so they
+# can't last-wins-override an Apply that re-sets the same first-class
+# field.
+_CONTEXT_FLAGS: frozenset[str] = frozenset({"-c", "--ctx-size"})
+_CACHE_FLAGS: frozenset[str] = frozenset(
+    {"-ctk", "--cache-type-k", "-ctv", "--cache-type-v"}
+)
+_SPEC_FLAGS: frozenset[str] = frozenset(
+    {
+        "--spec-default",
+        "--spec-type",
+        "--spec-ngram-size-n",
+        "--spec-ngram-size",
+        "--draft-min",
+        "--draft-max",
+    }
+)
+_TEMPLATE_FLAGS: frozenset[str] = frozenset(
+    {
+        "--chat-template",
+        "--chat-template-file",
+        "--chat-template-kwargs",
+        "--jinja",
+        "--no-jinja",
+    }
+)
+
+_SHADOWING_FLAGS: frozenset[str] = (
+    _CONTEXT_FLAGS | _CACHE_FLAGS | _SPEC_FLAGS | _TEMPLATE_FLAGS
+)
+
+# Boolean flags inside _SHADOWING_FLAGS that take no value. The
+# value-consuming heuristic in strip_shadowing_flags must skip just the
+# flag for these, never the following token.
+_BOOLEAN_SHADOWING_FLAGS: frozenset[str] = frozenset(
+    {"--spec-default", "--jinja", "--no-jinja"}
+)
+
+
+def strip_shadowing_flags(
+    args: Iterable[str],
+    *,
+    strip_context: bool = True,
+    strip_cache: bool = True,
+    strip_spec: bool = True,
+    strip_template: bool = True,
+) -> list[str]:
+    """Strip flags that shadow first-class Studio settings.
+
+    Used when the route inherits a previous load's ``llama_extra_args``
+    so that an inherited ``-c 4096`` cannot override the current
+    request's ``max_seq_length`` (and equivalents for cache /
+    speculative / chat template). Each ``strip_*`` flag controls one
+    group; the route only strips groups whose corresponding first-class
+    field was actually supplied by the caller, so an inherited
+    ``--chat-template-file`` survives an Apply that omits both
+    ``llama_extra_args`` and ``chat_template_override``.
+    """
+    shadowing: set[str] = set()
+    if strip_context:
+        shadowing |= _CONTEXT_FLAGS
+    if strip_cache:
+        shadowing |= _CACHE_FLAGS
+    if strip_spec:
+        shadowing |= _SPEC_FLAGS
+    if strip_template:
+        shadowing |= _TEMPLATE_FLAGS
+
+    tokens = [str(a) for a in (args or [])]
+    out: list[str] = []
+    i, n = 0, len(tokens)
+    while i < n:
+        tok = tokens[i]
+        flag = _flag_name(tok)
+        if flag is None or flag not in shadowing:
+            out.append(tok)
+            i += 1
+            continue
+        # Drop this token. Boolean shadowing flags never carry a value;
+        # other shadowing flags consume the next token when it isn't a
+        # flag and the value isn't already packed as ``--key=value``.
+        if flag in _BOOLEAN_SHADOWING_FLAGS or "=" in tok:
+            i += 1
+        elif i + 1 < n and _flag_name(tokens[i + 1]) is None:
+            i += 2
+        else:
+            i += 1
+    return out
diff --git a/studio/backend/core/inference/mlx_inference.py b/studio/backend/core/inference/mlx_inference.py
index ee7c9bbd51..e7bce2d33e 100644
--- a/studio/backend/core/inference/mlx_inference.py
+++ b/studio/backend/core/inference/mlx_inference.py
@@ -116,11 +116,11 @@ class MLXInferenceBackend:
         )
 
         try:
-            from unsloth_zoo.mlx_loader import FastMLXModel
+            from unsloth_zoo.mlx.loader import FastMLXModel
         except ImportError as e:
             raise ImportError(
                 "Unsloth: MLX inference requires unsloth-zoo with the MLX modules "
-                "(unsloth_zoo.mlx_loader). Reinstall via install.sh on Apple Silicon."
+                "(unsloth_zoo.mlx.loader). Reinstall via install.sh on Apple Silicon."
             ) from e
 
         model, tokenizer_or_processor = FastMLXModel.from_pretrained(
diff --git a/studio/backend/core/inference/providers.py b/studio/backend/core/inference/providers.py
index 4b6d7d6b17..143ced95f1 100644
--- a/studio/backend/core/inference/providers.py
+++ b/studio/backend/core/inference/providers.py
@@ -218,6 +218,28 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
         # are always among the top regardless of the API's order.
         "model_id_limit": 15,
     },
+    "vllm": {
+        "display_name": "vLLM",
+        # User-supplied via provider_base_url; the route layer already falls
+        # back to the payload's base_url when the registry entry has none.
+        "base_url": "",
+        "default_models": [],
+        "supports_streaming": True,
+        "supports_vision": True,
+        "supports_tool_calling": True,
+        "auth_header": "Authorization",
+        "auth_prefix": "Bearer ",
+        # Force /v1/chat/completions in stream_chat_completion — vLLM's
+        # /v1/responses rebuilds messages and runs them through the loaded
+        # model's chat template, which 400s on strict-alternation templates
+        # (Gemma 3 raises "Conversation roles must alternate user/assistant
+        # /user/assistant/..."). The chat-completions path takes messages
+        # verbatim and avoids that template gauntlet.
+        "notes": "Self-hosted vLLM server. Always routed to /v1/chat/completions.",
+        # Surfaced through the frontend's CUSTOM_PROVIDER_PRESETS, not the
+        # /api/providers/registry dropdown — see list_available_providers.
+        "hidden": True,
+    },
     "openrouter": {
         "display_name": "OpenRouter",
         "base_url": "https://openrouter.ai/api/v1",
@@ -269,9 +291,17 @@ def get_base_url(provider_type: str) -> str | None:
 
 
 def list_available_providers() -> list[dict[str, Any]]:
-    """Return all registered providers (for the /registry endpoint)."""
+    """Return all registered providers (for the /registry endpoint).
+
+    Hidden entries (``"hidden": True``) are filtered out — they exist in the
+    registry only for backend lookups (e.g. ``supports_vision`` for vLLM) and
+    are surfaced in the frontend via ``CUSTOM_PROVIDER_PRESETS`` instead of
+    the cloud-provider dropdown.
+    """
     result = []
     for provider_type, info in PROVIDER_REGISTRY.items():
+        if info.get("hidden"):
+            continue
         result.append(
             {
                 "provider_type": provider_type,
diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py
index a3f063694f..62f1e23e60 100644
--- a/studio/backend/core/training/trainer.py
+++ b/studio/backend/core/training/trainer.py
@@ -3208,6 +3208,9 @@ class UnslothTrainer:
                 if eval_steps_val > 0:
                     config_args["eval_strategy"] = "steps"
                     config_args["eval_steps"] = eval_steps_val
+                    config_args["per_device_eval_batch_size"] = config_args[
+                        "per_device_train_batch_size"
+                    ]
                     logger.info(
                         f"✅ Evaluation enabled: eval_steps={eval_steps_val} (fraction of total steps)\n"
                     )
diff --git a/studio/backend/core/training/training.py b/studio/backend/core/training/training.py
index 549d733252..e4abb64b8b 100644
--- a/studio/backend/core/training/training.py
+++ b/studio/backend/core/training/training.py
@@ -214,6 +214,7 @@ class TrainingBackend:
             "max_steps": kwargs.get("max_steps", 0),
             "save_steps": kwargs.get("save_steps", 0),
             "weight_decay": kwargs.get("weight_decay", 0.001),
+            "max_grad_norm": kwargs.get("max_grad_norm", 0.0),
             "random_seed": kwargs.get("random_seed", 3407),
             "packing": kwargs.get("packing", False),
             "optim": kwargs.get("optim", "adamw_8bit"),
diff --git a/studio/backend/core/training/worker.py b/studio/backend/core/training/worker.py
index ef5cafb175..4434436ca3 100644
--- a/studio/backend/core/training/worker.py
+++ b/studio/backend/core/training/worker.py
@@ -30,6 +30,7 @@ from utils.hardware import apply_gpu_ids
 from utils.wheel_utils import (
     direct_wheel_url,
     flash_attn_wheel_url,
+    has_blackwell_gpu,
     install_wheel,
     probe_torch_wheel_env,
     url_exists,
@@ -313,6 +314,12 @@ def _should_try_runtime_flash_attn_install(max_seq_length: int) -> bool:
 def _ensure_flash_attn_for_long_context(event_queue: Any, max_seq_length: int) -> None:
     if not _should_try_runtime_flash_attn_install(max_seq_length):
         return
+    if has_blackwell_gpu():
+        _send_status(
+            event_queue,
+            "Skipping flash-attn install: Blackwell GPU detected (sm_100+); no compatible prebuilt wheel",
+        )
+        return
 
     installed = _install_package_wheel_first(
         event_queue = event_queue,
@@ -417,6 +424,55 @@ def _normalize_mlx_studio_scheduler(value):
     return raw
 
 
+def _resolve_mlx_local_dataset_files(file_paths: list) -> list[str]:
+    """Resolve Studio local dataset uploads without importing the GPU trainer."""
+    from utils.paths import resolve_dataset_path
+
+    all_files: list[str] = []
+    for dataset_file in file_paths or []:
+        file_path = (
+            dataset_file
+            if os.path.isabs(dataset_file)
+            else str(resolve_dataset_path(dataset_file))
+        )
+        file_path_obj = Path(file_path)
+
+        if file_path_obj.is_dir():
+            parquet_dir = (
+                file_path_obj / "parquet-files"
+                if (file_path_obj / "parquet-files").exists()
+                else file_path_obj
+            )
+            parquet_files = sorted(parquet_dir.glob("*.parquet"))
+            if parquet_files:
+                all_files.extend(str(p) for p in parquet_files)
+                continue
+
+            candidates: list[Path] = []
+            for ext in (".json", ".jsonl", ".csv", ".parquet"):
+                candidates.extend(sorted(file_path_obj.glob(f"*{ext}")))
+            if candidates:
+                all_files.extend(str(c) for c in candidates)
+                continue
+
+            raise ValueError(f"No supported data files in directory: {file_path_obj}")
+
+        all_files.append(str(file_path_obj))
+
+    return all_files
+
+
+def _mlx_local_dataset_loader_for_files(files: list[str]) -> str:
+    first_ext = Path(files[0]).suffix.lower()
+    if first_ext in (".json", ".jsonl"):
+        return "json"
+    if first_ext == ".csv":
+        return "csv"
+    if first_ext == ".parquet":
+        return "parquet"
+    raise ValueError(f"Unsupported dataset format: {files[0]}")
+
+
 def _run_mlx_training(event_queue, stop_queue, config):
     """Self-contained MLX training path for Apple Silicon.
 
@@ -442,8 +498,8 @@ def _run_mlx_training(event_queue, stop_queue, config):
     import mlx.core as mx
 
     try:
-        from unsloth_zoo.mlx_loader import FastMLXModel
-        from unsloth_zoo.mlx_trainer import (
+        from unsloth_zoo.mlx.loader import FastMLXModel
+        from unsloth_zoo.mlx.trainer import (
             MLXTrainer,
             MLXTrainingConfig,
             train_on_responses_only,
@@ -451,7 +507,7 @@ def _run_mlx_training(event_queue, stop_queue, config):
     except ImportError as e:
         raise ImportError(
             "Unsloth: MLX training requires unsloth-zoo with the MLX modules "
-            "(unsloth_zoo.mlx_loader / unsloth_zoo.mlx_trainer). Reinstall via "
+            "(unsloth_zoo.mlx.loader / unsloth_zoo.mlx.trainer). Reinstall via "
             "install.sh on Apple Silicon."
         ) from e
     from datasets import load_dataset
@@ -572,7 +628,6 @@ def _run_mlx_training(event_queue, stop_queue, config):
         return ds
 
     def _load_local(file_paths):
-        from core.training.trainer import UnslothTrainer
         from datasets import load_from_disk
 
         if len(file_paths) == 1:
@@ -581,10 +636,10 @@ def _run_mlx_training(event_queue, stop_queue, config):
                 (p / "dataset_info.json").exists() or (p / "state.json").exists()
             ):
                 return load_from_disk(str(p))
-        all_files = UnslothTrainer._resolve_local_files(file_paths)
+        all_files = _resolve_mlx_local_dataset_files(file_paths)
         if not all_files:
             raise ValueError("No local dataset files found")
-        loader = UnslothTrainer._loader_for_files(all_files)
+        loader = _mlx_local_dataset_loader_for_files(all_files)
         return load_dataset(loader, data_files = all_files, split = "train")
 
     if hf_dataset:
@@ -718,6 +773,12 @@ def _run_mlx_training(event_queue, stop_queue, config):
     else:
         eval_steps_val = int(eval_steps_val)
 
+    # MLX: per-element clip to [-1, 1]; norm clip disabled (it needs a
+    # global reduction that breaks MLX's eager pipeline). 1.0 (not 5.0):
+    # |g_i| > 5 rarely fires, so the historical 5.0 was effectively no-op.
+    max_grad_norm = 0.0
+    max_grad_value = 1.0  # TODO: expose MLX grad-clip in Studio UI for power users
+
     trainer = MLXTrainer(
         model = model,
         tokenizer = tokenizer,
@@ -732,6 +793,8 @@ def _run_mlx_training(event_queue, stop_queue, config):
             lr_scheduler_type = lr_scheduler_type,
             optim = optim_name,
             weight_decay = float(config.get("weight_decay", 0.001) or 0.001),
+            max_grad_norm = max_grad_norm,
+            max_grad_value = max_grad_value,
             logging_steps = 1,
             max_seq_length = max_seq_length,
             seed = config.get("random_seed", 3407),
@@ -820,7 +883,17 @@ def _run_mlx_training(event_queue, stop_queue, config):
     # ── 9. Real-time progress callback ──
     _send("status", status_message = f"Training {model_name}...")
 
-    def _on_step(step, total, loss, lr, tok_s, peak_gb, elapsed, num_tokens):
+    def _on_step(
+        step,
+        total,
+        loss,
+        lr,
+        tok_s,
+        peak_gb,
+        elapsed,
+        num_tokens,
+        grad_norm = None,
+    ):
         eta = (elapsed / step * (total - step)) if step > 0 else 0
         _send(
             "progress",
@@ -831,7 +904,7 @@ def _run_mlx_training(event_queue, stop_queue, config):
             total_steps = total,
             elapsed_seconds = elapsed,
             eta_seconds = max(0, eta),
-            grad_norm = None,
+            grad_norm = grad_norm,
             num_tokens = num_tokens,
             eval_loss = None,
             status_message = None,
@@ -846,6 +919,11 @@ def _run_mlx_training(event_queue, stop_queue, config):
                         "train/tokens_per_sec": tok_s,
                         "train/peak_gb": peak_gb,
                         "train/num_tokens": num_tokens,
+                        **(
+                            {"train/grad_norm": grad_norm}
+                            if grad_norm is not None
+                            else {}
+                        ),
                     },
                     step = step,
                 )
@@ -857,6 +935,8 @@ def _run_mlx_training(event_queue, stop_queue, config):
                 tb_writer.add_scalar("train/learning_rate", lr, step)
                 tb_writer.add_scalar("train/tokens_per_sec", tok_s, step)
                 tb_writer.add_scalar("train/peak_gb", peak_gb, step)
+                if grad_norm is not None:
+                    tb_writer.add_scalar("train/grad_norm", grad_norm, step)
             except Exception:
                 pass
 
diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py
index 013328f6c6..3aa89cc934 100644
--- a/studio/backend/models/inference.py
+++ b/studio/backend/models/inference.py
@@ -593,6 +593,92 @@ class ChatCompletionRequest(BaseModel):
         None,
         description = "[x-unsloth] Override base URL for the external provider.",
     )
+    enable_prompt_caching: Optional[bool] = Field(
+        None,
+        description = (
+            "[x-unsloth] Opt in to provider-side prompt caching. On Anthropic, "
+            "attaches cache_control={type:ephemeral} to the system block so the "
+            "static prefix is reused across turns. On OpenAI cloud, caching is "
+            "automatic for prompts >=1024 tokens and this flag is informational. "
+            "Ignored for every other provider (mistral, gemini, kimi, openrouter, "
+            "vllm, local, etc.). Treated as enabled when omitted."
+        ),
+    )
+    openai_code_exec_container_id: Optional[str] = Field(
+        None,
+        description = (
+            "[x-unsloth] OpenAI shell-tool container id from the prior response "
+            "in the same chat thread. When set and `code_execution` is in "
+            "`enabled_tools`, the next /v1/responses call uses "
+            "environment.type='container_reference' so filesystem state "
+            "persists across turns. Unset → environment.type='container_auto' "
+            "and OpenAI creates a fresh container. Only meaningful for the "
+            "OpenAI cloud + gpt-5.5 family path; ignored otherwise."
+        ),
+    )
+
+
+# ── OpenAI shell-tool container management ─────────────────────
+
+
+class OpenAIContainerRequest(BaseModel):
+    """
+    Shared body for the three OpenAI container endpoints (list / create
+    / delete). Carries the encrypted API key + base URL so the route
+    handler can decrypt it and proxy to the user's OpenAI account.
+    Same pattern as the inference proxy endpoints — keeps the key off
+    persistent storage on the backend.
+    """
+
+    encrypted_api_key: str = Field(
+        ...,
+        description = "[x-unsloth] RSA-encrypted, base64-encoded OpenAI API key.",
+    )
+    provider_base_url: Optional[str] = Field(
+        None,
+        description = "[x-unsloth] OpenAI base URL. Only api.openai.com is supported; non-cloud bases are rejected with 400.",
+    )
+
+
+class CreateOpenAIContainerBody(OpenAIContainerRequest):
+    name: str = Field(
+        ...,
+        min_length = 1,
+        max_length = 256,
+        description = "Human-readable container name. Surfaces in the picker UI.",
+    )
+    ttl_minutes: int = Field(
+        20,
+        ge = 1,
+        le = 20,
+        description = (
+            "Idle-timeout TTL the new container will inherit (anchor="
+            "last_active_at). OpenAI hard-caps this at 20 minutes and "
+            "rejects larger values with integer_above_max_value."
+        ),
+    )
+
+
+class DeleteOpenAIContainerBody(OpenAIContainerRequest):
+    container_id: str = Field(
+        ...,
+        description = "OpenAI container id (cntr_...) to delete.",
+    )
+
+
+class OpenAIContainerSummary(BaseModel):
+    """One row from GET /v1/containers, reshaped for the UI."""
+
+    id: str
+    name: Optional[str] = None
+    created_at: Optional[int] = None
+    last_active_at: Optional[int] = None
+    expires_after_minutes: Optional[int] = None
+    status: Optional[str] = None
+
+
+class ListOpenAIContainersResponse(BaseModel):
+    containers: list[OpenAIContainerSummary]
 
 
 # ── Streaming response chunks ────────────────────────────────────
diff --git a/studio/backend/models/providers.py b/studio/backend/models/providers.py
index 5678e69f62..53ce981392 100644
--- a/studio/backend/models/providers.py
+++ b/studio/backend/models/providers.py
@@ -95,8 +95,9 @@ class ProviderModelsRequest(BaseModel):
     """Request to list models from an external provider."""
 
     provider_type: str = Field(..., description = "Provider type from the registry")
-    encrypted_api_key: str = Field(
-        ..., description = "RSA-encrypted, base64-encoded API key"
+    encrypted_api_key: Optional[str] = Field(
+        None,
+        description = "RSA-encrypted, base64-encoded API key (optional for local providers)",
     )
     base_url: Optional[str] = Field(
         None, description = "Custom base URL (overrides registry default)"
@@ -110,8 +111,9 @@ class ProviderTestRequest(BaseModel):
     """Request to test connectivity to an external provider."""
 
     provider_type: str = Field(..., description = "Provider type from the registry")
-    encrypted_api_key: str = Field(
-        ..., description = "RSA-encrypted, base64-encoded API key"
+    encrypted_api_key: Optional[str] = Field(
+        None,
+        description = "RSA-encrypted, base64-encoded API key (optional for local providers)",
     )
     base_url: Optional[str] = Field(
         None, description = "Custom base URL (overrides registry default)"
diff --git a/studio/backend/models/training.py b/studio/backend/models/training.py
index 31f1d575d7..7c53b0fee5 100644
--- a/studio/backend/models/training.py
+++ b/studio/backend/models/training.py
@@ -262,6 +262,11 @@ class TrainingStartRequest(BaseModel):
     max_steps: Optional[int] = Field(None, description = "Maximum training steps")
     save_steps: int = Field(100, description = "Steps between checkpoints")
     weight_decay: float = Field(0.001, description = "Weight decay")
+    max_grad_norm: float = Field(
+        0.0,
+        ge = 0,
+        description = "Global gradient norm clipping threshold. Set 0 to disable.",
+    )
     random_seed: int = Field(42, description = "Random seed")
     packing: bool = Field(False, description = "Enable sequence packing")
     optim: str = Field("adamw_8bit", description = "Optimizer")
diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py
index 59928be3cf..60078ecc9b 100644
--- a/studio/backend/routes/inference.py
+++ b/studio/backend/routes/inference.py
@@ -119,7 +119,10 @@ try:
         _DEFAULT_T_MAX_PREDICT_MS,
         detect_reasoning_flags,
     )
-    from core.inference.llama_server_args import validate_extra_args
+    from core.inference.llama_server_args import (
+        strip_shadowing_flags,
+        validate_extra_args,
+    )
     from utils.models import ModelConfig
     from utils.inference import load_inference_config
     from utils.models.model_config import load_model_defaults
@@ -141,7 +144,10 @@ except ImportError:
         _DEFAULT_T_MAX_PREDICT_MS,
         detect_reasoning_flags,
     )
-    from core.inference.llama_server_args import validate_extra_args
+    from core.inference.llama_server_args import (
+        strip_shadowing_flags,
+        validate_extra_args,
+    )
     from utils.models import ModelConfig
     from utils.inference import load_inference_config
     from utils.models.model_config import load_model_defaults
@@ -194,6 +200,11 @@ from models.inference import (
     AnthropicResponseTextBlock,
     AnthropicResponseToolUseBlock,
     AnthropicUsage,
+    CreateOpenAIContainerBody,
+    DeleteOpenAIContainerBody,
+    ListOpenAIContainersResponse,
+    OpenAIContainerRequest,
+    OpenAIContainerSummary,
 )
 from core.inference.anthropic_compat import (
     anthropic_messages_to_openai,
@@ -401,6 +412,57 @@ def _validate_native_mmproj_companion(
         ) from exc
 
 
+def _normalise_settings_str(value: Optional[str]) -> Optional[str]:
+    """Lowercase + strip a settings string, mapping blank/None to None."""
+    if value is None:
+        return None
+    if isinstance(value, str):
+        stripped = value.strip().lower()
+        return stripped or None
+    return value
+
+
+def _request_matches_loaded_settings(
+    request: LoadRequest, llama_backend: LlamaCppBackend
+) -> bool:
+    """True iff every runtime setting on the request matches the loaded
+    server. Caller has already checked model+variant+is_loaded. See #5401."""
+    # Compare requested n_ctx (not effective) so VRAM-cap doesn't mask
+    # an Auto-vs-explicit slider flip.
+    if request.max_seq_length != llama_backend.requested_n_ctx:
+        return False
+    if _normalise_settings_str(request.cache_type_kv) != _normalise_settings_str(
+        llama_backend.cache_type_kv
+    ):
+        return False
+    # Vision loads silently drop speculative decoding (llama_cpp.py gates
+    # spec on ``not is_vision``), so treat the request as ``off`` against
+    # the backend's ``None`` to avoid forcing a redundant reload.
+    if llama_backend.is_vision:
+        req_spec = "off"
+    else:
+        req_spec = _normalise_settings_str(request.speculative_type) or "off"
+    backend_spec = _normalise_settings_str(llama_backend.speculative_type) or "off"
+    if req_spec != backend_spec:
+        return False
+    if (request.chat_template_override or None) != (
+        llama_backend.chat_template_override or None
+    ):
+        return False
+    # llama_extra_args=None means "inherit"; only an explicit list that
+    # differs forces a reload. On the inherit path, refuse to match if
+    # stored extras contain any shadow flag, so the reload path can
+    # strip them instead of leaving a stale override in effect.
+    backend_extra = list(llama_backend.extra_args) if llama_backend.extra_args else []
+    if request.llama_extra_args is None:
+        if backend_extra and strip_shadowing_flags(backend_extra) != backend_extra:
+            return False
+    else:
+        if list(request.llama_extra_args) != backend_extra:
+            return False
+    return True
+
+
 def _resolve_model_identifier_for_request(
     request: LoadRequest | ValidateModelRequest,
     *,
@@ -456,6 +518,11 @@ async def load_model(
             extra_llama_args = validate_extra_args(request.llama_extra_args)
         except ValueError as exc:
             raise HTTPException(status_code = 400, detail = str(exc))
+        # Re-narrow []-from-None back to None so the inheritance path
+        # below can tell "caller omitted" from "caller explicit []".
+        extra_llama_args: Optional[list[str]] = (
+            None if request.llama_extra_args is None else extra_llama_args
+        )
 
         model_identifier, model_log_label, native_grant_backed = (
             _resolve_model_identifier_for_request(request, operation = "load-model")
@@ -474,6 +541,9 @@ async def load_model(
                 and llama_backend.hf_variant.lower() == request.gguf_variant.lower()
                 and llama_backend.model_identifier
                 and llama_backend.model_identifier.lower() == model_identifier.lower()
+                # Also require runtime settings to match so Apply changes
+                # aren't silently dropped (#5401).
+                and _request_matches_loaded_settings(request, llama_backend)
             ):
                 logger.info(
                     f"Model already loaded (GGUF): {model_log_label} variant={request.gguf_variant}, skipping reload"
@@ -608,6 +678,70 @@ async def load_model(
                 )
                 unsloth_backend.unload_model(unsloth_backend.active_model_name)
 
+            # Inherit llama_extra_args from the previous load when the
+            # request omits the field (the chat-settings Apply path
+            # does not round-trip them; explicit [] still clears).
+            # Inheritance is gated on (model_identifier, hf_variant)
+            # to refuse cross-model pickup, and shadowing flags are
+            # stripped so an inherited override can't win the last-wins
+            # CLI parse against a freshly-supplied first-class field.
+            if request.llama_extra_args is None and llama_backend.extra_args:
+                source = llama_backend.extra_args_source
+                # Compare against the resolved variant, not the request
+                # field: callers commonly omit gguf_variant for local
+                # ``.gguf`` paths and HF auto-pick flows. ``config.gguf_
+                # variant`` is the variant load_model was actually
+                # invoked with (see the HF / local branches below), so
+                # both sides of the comparison key off the same string.
+                resolved_variant = config.gguf_variant
+                same_source = bool(
+                    source
+                    and source[0]
+                    and source[0].lower() == model_identifier.lower()
+                    and (source[1] or "").lower() == (resolved_variant or "").lower()
+                )
+                if not same_source:
+                    logger.info(
+                        "Not inheriting llama_extra_args: stored args came "
+                        "from %s, loading %s",
+                        source,
+                        (model_identifier, resolved_variant),
+                    )
+                    # Cross-model: clear explicitly so the backend
+                    # doesn't inherit via "no opinion" semantics.
+                    extra_llama_args = []
+                else:
+                    # Strip only the groups whose first-class field
+                    # was actually set by the caller, so an inherited
+                    # --chat-template-file survives an Apply that omits
+                    # chat_template_override.
+                    fields_set = getattr(request, "model_fields_set", set())
+                    stripped = strip_shadowing_flags(
+                        llama_backend.extra_args,
+                        strip_context = "max_seq_length" in fields_set,
+                        strip_cache = "cache_type_kv" in fields_set,
+                        strip_spec = "speculative_type" in fields_set,
+                        strip_template = "chat_template_override" in fields_set,
+                    )
+                    try:
+                        extra_llama_args = validate_extra_args(stripped)
+                    except ValueError:
+                        # Should not happen on already-validated args; degrade
+                        # to no-extras rather than 400 if managed flags changed.
+                        logger.warning(
+                            "Stored llama_extra_args failed revalidation; "
+                            "loading without them: %s",
+                            stripped,
+                        )
+                        extra_llama_args = []
+                    else:
+                        if extra_llama_args:
+                            logger.info(
+                                "Inheriting llama_extra_args from previous "
+                                "load (same model, shadow-stripped): %s",
+                                extra_llama_args,
+                            )
+
             # Route to HF mode or local mode based on config
             # Run in a thread so the event loop stays free for progress
             # polling and other requests during the (potentially long)
@@ -640,6 +774,10 @@ async def load_model(
                     llama_backend.load_model,
                     gguf_path = config.gguf_file,
                     mmproj_path = config.gguf_mmproj_file,
+                    # Pass the resolved variant so _extra_args_source
+                    # is keyed off the same string the inheritance
+                    # check at the top of /load uses (#5401 followup).
+                    hf_variant = config.gguf_variant,
                     model_identifier = config.identifier,
                     is_vision = config.is_vision,
                     n_ctx = request.max_seq_length,
@@ -1554,15 +1692,16 @@ async def _proxy_to_external_provider(
             detail = f"Unknown provider type: {provider_type}",
         )
 
-    # Decrypt the API key
-    try:
-        api_key = decrypt_api_key(payload.encrypted_api_key)
-    except Exception as exc:
-        logger.warning("external_provider.decrypt_failed", error = str(exc))
-        raise HTTPException(
-            status_code = 400,
-            detail = "Failed to decrypt API key. The server key may have changed — try refreshing the page.",
-        )
+    api_key = ""
+    if payload.encrypted_api_key:
+        try:
+            api_key = decrypt_api_key(payload.encrypted_api_key)
+        except Exception as exc:
+            logger.warning("external_provider.decrypt_failed", error = str(exc))
+            raise HTTPException(
+                status_code = 400,
+                detail = "Failed to decrypt API key. The server key may have changed — try refreshing the page.",
+            )
 
     model = payload.external_model or payload.model
     if model == "default":
@@ -1595,6 +1734,9 @@ async def _proxy_to_external_provider(
             top_k = payload.top_k,
             enable_thinking = payload.enable_thinking,
             reasoning_effort = payload.reasoning_effort,
+            enabled_tools = payload.enabled_tools,
+            enable_prompt_caching = payload.enable_prompt_caching,
+            openai_code_exec_container_id = payload.openai_code_exec_container_id,
             stream = payload.stream,
         )
         try:
@@ -1624,6 +1766,186 @@ async def _proxy_to_external_provider(
     )
 
 
+# ── OpenAI shell-tool container management ───────────────────────
+
+
+def _resolve_openai_cloud_client(
+    body: OpenAIContainerRequest,
+) -> ExternalProviderClient:
+    """
+    Decrypt the API key + validate the base URL points at OpenAI cloud,
+    then build an ExternalProviderClient for the three container CRUD
+    endpoints below. The shell tool only exists on api.openai.com, so
+    rejecting non-cloud bases up front prevents confusing 404s on
+    ollama / llama.cpp / vLLM / custom presets.
+    """
+    base_url = body.provider_base_url or get_base_url("openai")
+    if not base_url or "api.openai.com" not in base_url:
+        raise HTTPException(
+            status_code = 400,
+            detail = (
+                "OpenAI container management is only available on the "
+                "managed cloud (api.openai.com). The provider's base URL "
+                f"points at {base_url!r}."
+            ),
+        )
+    try:
+        api_key = decrypt_api_key(body.encrypted_api_key)
+    except Exception as exc:
+        logger.warning("external_provider.decrypt_failed", error = str(exc))
+        raise HTTPException(
+            status_code = 400,
+            detail = "Failed to decrypt API key. The server key may have changed — try refreshing the page.",
+        )
+    return ExternalProviderClient(
+        provider_type = "openai",
+        base_url = base_url,
+        api_key = api_key,
+    )
+
+
+def _summarize_container(raw: dict) -> OpenAIContainerSummary:
+    expires = raw.get("expires_after")
+    expires_minutes: Optional[int] = None
+    if isinstance(expires, dict):
+        minutes = expires.get("minutes")
+        if isinstance(minutes, int):
+            expires_minutes = minutes
+    return OpenAIContainerSummary(
+        id = str(raw.get("id") or ""),
+        name = raw.get("name"),
+        created_at = raw.get("created_at")
+        if isinstance(raw.get("created_at"), int)
+        else None,
+        last_active_at = raw.get("last_active_at")
+        if isinstance(raw.get("last_active_at"), int)
+        else None,
+        expires_after_minutes = expires_minutes,
+        status = raw.get("status") if isinstance(raw.get("status"), str) else None,
+    )
+
+
+@router.post(
+    "/external/openai/containers/list",
+    response_model = ListOpenAIContainersResponse,
+)
+async def list_openai_containers(
+    body: OpenAIContainerRequest,
+    current_subject: str = Depends(get_current_subject),
+) -> ListOpenAIContainersResponse:
+    """List the user's OpenAI shell-tool containers."""
+    client = _resolve_openai_cloud_client(body)
+    try:
+        try:
+            raw = await client.list_openai_containers()
+        except httpx.HTTPStatusError as exc:
+            detail = exc.response.text[:500] if exc.response is not None else str(exc)
+            raise HTTPException(
+                status_code = exc.response.status_code if exc.response else 502,
+                detail = f"OpenAI rejected /containers list: {detail}",
+            )
+        except httpx.HTTPError as exc:
+            raise HTTPException(
+                status_code = 502,
+                detail = f"Failed to reach OpenAI: {exc}",
+            )
+        # OpenAI keeps expired containers in /v1/containers indefinitely
+        # with status="expired" — they're effectively dead but still
+        # listed. Hide them so the picker only shows usable containers.
+        return ListOpenAIContainersResponse(
+            containers = [
+                _summarize_container(c)
+                for c in raw
+                if isinstance(c, dict) and c.get("status") != "expired"
+            ],
+        )
+    finally:
+        await client.close()
+
+
+@router.post(
+    "/external/openai/containers/create",
+    response_model = OpenAIContainerSummary,
+)
+async def create_openai_container(
+    body: CreateOpenAIContainerBody,
+    current_subject: str = Depends(get_current_subject),
+) -> OpenAIContainerSummary:
+    """Create a named container with the user-chosen idle TTL."""
+    client = _resolve_openai_cloud_client(body)
+    try:
+        try:
+            raw = await client.create_openai_container(
+                name = body.name,
+                ttl_minutes = body.ttl_minutes,
+            )
+        except httpx.HTTPStatusError as exc:
+            detail = exc.response.text[:500] if exc.response is not None else str(exc)
+            raise HTTPException(
+                status_code = exc.response.status_code if exc.response else 502,
+                detail = f"OpenAI rejected /containers create: {detail}",
+            )
+        except httpx.HTTPError as exc:
+            raise HTTPException(
+                status_code = 502,
+                detail = f"Failed to reach OpenAI: {exc}",
+            )
+        if not isinstance(raw, dict):
+            raise HTTPException(
+                status_code = 502,
+                detail = "OpenAI returned an unexpected container payload.",
+            )
+        return _summarize_container(raw)
+    finally:
+        await client.close()
+
+
+@router.post("/external/openai/containers/delete", status_code = 204)
+async def delete_openai_container(
+    body: DeleteOpenAIContainerBody,
+    current_subject: str = Depends(get_current_subject),
+) -> None:
+    """Delete a named container by id."""
+    logger.info(
+        "openai_container_delete.request subject=%s container_id=%s base_url=%s",
+        current_subject,
+        body.container_id,
+        body.provider_base_url,
+    )
+    client = _resolve_openai_cloud_client(body)
+    try:
+        try:
+            await client.delete_openai_container(body.container_id)
+            logger.info(
+                "openai_container_delete.success container_id=%s",
+                body.container_id,
+            )
+        except httpx.HTTPStatusError as exc:
+            detail = exc.response.text[:500] if exc.response is not None else str(exc)
+            logger.warning(
+                "openai_container_delete.openai_rejected container_id=%s status=%s body=%s",
+                body.container_id,
+                exc.response.status_code if exc.response else None,
+                detail,
+            )
+            raise HTTPException(
+                status_code = exc.response.status_code if exc.response else 502,
+                detail = f"OpenAI rejected /containers delete: {detail}",
+            )
+        except httpx.HTTPError as exc:
+            logger.warning(
+                "openai_container_delete.transport_error container_id=%s error=%s",
+                body.container_id,
+                exc,
+            )
+            raise HTTPException(
+                status_code = 502,
+                detail = f"Failed to reach OpenAI: {exc}",
+            )
+    finally:
+        await client.close()
+
+
 @router.post("/chat/completions")
 async def openai_chat_completions(
     payload: ChatCompletionRequest,
@@ -1644,7 +1966,8 @@ async def openai_chat_completions(
     - Other models → Unsloth/transformers via InferenceBackend
     """
     # ── External provider routing ────────────────────────────────
-    if payload.encrypted_api_key and (payload.provider_id or payload.provider_type):
+    # encrypted_api_key is optional — local providers (llama.cpp / vLLM / Ollama) may run without auth.
+    if payload.provider_id or payload.provider_type:
         return await _proxy_to_external_provider(payload, request)
 
     llama_backend = get_llama_cpp_backend()
diff --git a/studio/backend/routes/providers.py b/studio/backend/routes/providers.py
index e21985d60b..acfaa6e427 100644
--- a/studio/backend/routes/providers.py
+++ b/studio/backend/routes/providers.py
@@ -200,14 +200,18 @@ async def test_provider(
             detail = f"Unknown provider type: {payload.provider_type}",
         )
 
-    try:
-        api_key = decrypt_api_key(payload.encrypted_api_key)
-    except Exception as exc:
-        logger.warning("Failed to decrypt API key (%s): %s", type(exc).__name__, exc)
-        raise HTTPException(
-            status_code = 400,
-            detail = "Failed to decrypt API key. The public key may have changed — try refreshing the page.",
-        )
+    api_key = ""
+    if payload.encrypted_api_key:
+        try:
+            api_key = decrypt_api_key(payload.encrypted_api_key)
+        except Exception as exc:
+            logger.warning(
+                "Failed to decrypt API key (%s): %s", type(exc).__name__, exc
+            )
+            raise HTTPException(
+                status_code = 400,
+                detail = "Failed to decrypt API key. The public key may have changed — try refreshing the page.",
+            )
 
     base_url = payload.base_url or info["base_url"]
     client = ExternalProviderClient(
@@ -265,14 +269,18 @@ async def list_provider_models(
             detail = f"Unknown provider type: {payload.provider_type}",
         )
 
-    try:
-        api_key = decrypt_api_key(payload.encrypted_api_key)
-    except Exception as exc:
-        logger.warning("Failed to decrypt API key (%s): %s", type(exc).__name__, exc)
-        raise HTTPException(
-            status_code = 400,
-            detail = "Failed to decrypt API key. The public key may have changed — try refreshing the page.",
-        )
+    api_key = ""
+    if payload.encrypted_api_key:
+        try:
+            api_key = decrypt_api_key(payload.encrypted_api_key)
+        except Exception as exc:
+            logger.warning(
+                "Failed to decrypt API key (%s): %s", type(exc).__name__, exc
+            )
+            raise HTTPException(
+                status_code = 400,
+                detail = "Failed to decrypt API key. The public key may have changed — try refreshing the page.",
+            )
 
     if info.get("model_list_mode") == "curated":
         return [
diff --git a/studio/backend/routes/training.py b/studio/backend/routes/training.py
index 19202f3883..6e2413b3e9 100644
--- a/studio/backend/routes/training.py
+++ b/studio/backend/routes/training.py
@@ -215,6 +215,7 @@ async def start_training(
             "max_steps": request.max_steps,
             "save_steps": request.save_steps,
             "weight_decay": request.weight_decay,
+            "max_grad_norm": request.max_grad_norm,
             "random_seed": request.random_seed,
             "packing": request.packing,
             "optim": request.optim,
diff --git a/studio/backend/tests/test_anthropic_code_execution.py b/studio/backend/tests/test_anthropic_code_execution.py
new file mode 100644
index 0000000000..b427ad2c0b
--- /dev/null
+++ b/studio/backend/tests/test_anthropic_code_execution.py
@@ -0,0 +1,419 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""
+Unit tests for Anthropic's server-side `code_execution_20250825` tool
+translation in `_stream_anthropic`.
+
+Covers:
+- Request body: when ``enabled_tools=["code_execution"]``, the outbound
+  ``tools`` array carries ``{"type": "code_execution_20250825", "name":
+  "code_execution"}`` and the ``anthropic-beta`` header includes
+  ``code-execution-2025-08-25``.
+- Combined request: ``enabled_tools=["web_search", "code_execution"]``
+  sends both tool entries; the beta header still merges the code-exec
+  flag onto whatever the registry contributed.
+- SSE translation: a `bash_code_execution` server_tool_use +
+  `bash_code_execution_tool_result` pair emits one tool_start and one
+  tool_end ``_toolEvent`` chunk with the expected arguments and result.
+- SSE translation: a `text_editor_code_execution` create + result emits
+  a tool_start with ``kind="text_editor"`` + parsed args, and tool_end
+  with ``"Created"`` (or ``"Updated"``) based on the ``is_file_update``
+  flag.
+- Error path: a ``bash_code_execution_tool_result_error`` with
+  ``error_code="container_expired"`` renders as ``"Error:
+  container_expired"`` in the tool_end ``result``.
+"""
+
+import asyncio
+import json
+
+import httpx
+
+from core.inference import external_provider as ep_mod
+from core.inference.external_provider import ExternalProviderClient
+
+
+def _drive(coro):
+    return asyncio.new_event_loop().run_until_complete(coro)
+
+
+async def _collect(agen):
+    out = []
+    async for line in agen:
+        out.append(line)
+    return out
+
+
+def _mock_http_client(monkeypatch, handler):
+    transport = httpx.MockTransport(handler)
+    monkeypatch.setattr(ep_mod, "_http_client", httpx.AsyncClient(transport = transport))
+
+
+def _make_client() -> ExternalProviderClient:
+    return ExternalProviderClient(
+        provider_type = "anthropic",
+        base_url = "https://api.anthropic.com/v1",
+        api_key = "sk-ant-test",
+    )
+
+
+def _anthropic_sse(events: list[dict]) -> bytes:
+    chunks: list[str] = []
+    for event in events:
+        chunks.append(f"event: {event['type']}")
+        chunks.append(f"data: {json.dumps(event)}")
+        chunks.append("")
+    return ("\n".join(chunks) + "\n").encode("utf-8")
+
+
+def _tool_events(lines: list[str]) -> list[dict]:
+    """Extract `_toolEvent` payloads from emitted SSE data lines."""
+    out: list[dict] = []
+    for line in lines:
+        if not line.startswith("data:"):
+            continue
+        raw = line[len("data:") :].strip()
+        if not raw or raw == "[DONE]":
+            continue
+        try:
+            parsed = json.loads(raw)
+        except json.JSONDecodeError:
+            continue
+        if isinstance(parsed, dict) and "_toolEvent" in parsed:
+            out.append(parsed["_toolEvent"])
+    return out
+
+
+def test_code_execution_tool_appended_to_request_body(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        captured["headers"] = dict(request.headers)
+        return httpx.Response(
+            200,
+            content = _anthropic_sse([{"type": "message_stop"}]),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        async for _ in client._stream_anthropic(
+            messages = [{"role": "user", "content": "compute 2 + 2"}],
+            model = "claude-opus-4-7",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = 4096,
+            enabled_tools = ["code_execution"],
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    body = captured["body"]
+    tools = body.get("tools") or []
+    assert {
+        "type": "code_execution_20250825",
+        "name": "code_execution",
+    } in tools
+    # No web_search entry when only code_execution is enabled.
+    assert all(t.get("type") != "web_search_20250305" for t in tools)
+    # Beta header carries the documented flag.
+    beta_header = captured["headers"].get("anthropic-beta", "")
+    assert "code-execution-2025-08-25" in beta_header
+
+
+def test_code_execution_with_web_search_sends_both_tools(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        captured["headers"] = dict(request.headers)
+        return httpx.Response(
+            200,
+            content = _anthropic_sse([{"type": "message_stop"}]),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        async for _ in client._stream_anthropic(
+            messages = [{"role": "user", "content": "look it up and chart it"}],
+            model = "claude-opus-4-7",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = 4096,
+            enabled_tools = ["web_search", "code_execution"],
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    tools = captured["body"].get("tools") or []
+    tool_types = {t.get("type") for t in tools if isinstance(t, dict)}
+    assert "web_search_20250305" in tool_types
+    assert "code_execution_20250825" in tool_types
+    assert "code-execution-2025-08-25" in captured["headers"].get("anthropic-beta", "")
+
+
+def test_no_code_execution_tool_when_pill_off(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        captured["headers"] = dict(request.headers)
+        return httpx.Response(
+            200,
+            content = _anthropic_sse([{"type": "message_stop"}]),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        async for _ in client._stream_anthropic(
+            messages = [{"role": "user", "content": "hi"}],
+            model = "claude-opus-4-7",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = 4096,
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    tools = captured["body"].get("tools") or []
+    assert all(t.get("type") != "code_execution_20250825" for t in tools)
+    # Beta header must NOT mention code-execution when the tool isn't on
+    # — that flag is opt-in only.
+    assert "code-execution-2025-08-25" not in captured["headers"].get(
+        "anthropic-beta", ""
+    )
+
+
+def test_bash_code_execution_emits_tool_start_and_end(monkeypatch):
+    sse_events = [
+        {"type": "message_start", "message": {"usage": {}}},
+        {
+            "type": "content_block_start",
+            "index": 0,
+            "content_block": {
+                "type": "server_tool_use",
+                "id": "srvtoolu_1",
+                "name": "bash_code_execution",
+            },
+        },
+        {
+            "type": "content_block_delta",
+            "index": 0,
+            "delta": {
+                "type": "input_json_delta",
+                "partial_json": '{"command": "ls -la"}',
+            },
+        },
+        {"type": "content_block_stop", "index": 0},
+        {
+            "type": "content_block_start",
+            "index": 1,
+            "content_block": {
+                "type": "bash_code_execution_tool_result",
+                "tool_use_id": "srvtoolu_1",
+                "content": {
+                    "type": "bash_code_execution_result",
+                    "stdout": "total 24\ndrwxr-xr-x 2 user user 4096 Jan 1 12:00 .",
+                    "stderr": "",
+                    "return_code": 0,
+                },
+            },
+        },
+        {"type": "content_block_stop", "index": 1},
+        {"type": "message_stop"},
+    ]
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(
+            200,
+            content = _anthropic_sse(sse_events),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        return await _collect(
+            client._stream_anthropic(
+                messages = [{"role": "user", "content": "list files"}],
+                model = "claude-opus-4-7",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = 4096,
+                enabled_tools = ["code_execution"],
+            )
+        )
+
+    lines = _drive(run())
+    events = _tool_events(lines)
+
+    assert len(events) == 2
+    start, end = events
+    assert start["type"] == "tool_start"
+    assert start["tool_name"] == "code_execution"
+    assert start["tool_call_id"] == "srvtoolu_1"
+    assert start["arguments"] == {"kind": "bash", "command": "ls -la"}
+
+    assert end["type"] == "tool_end"
+    assert end["tool_call_id"] == "srvtoolu_1"
+    assert "total 24" in end["result"]
+    # Non-zero return_code not present, so no return_code line.
+    assert "return_code:" not in end["result"]
+
+
+def test_text_editor_create_emits_kind_and_status(monkeypatch):
+    sse_events = [
+        {"type": "message_start", "message": {"usage": {}}},
+        {
+            "type": "content_block_start",
+            "index": 0,
+            "content_block": {
+                "type": "server_tool_use",
+                "id": "srvtoolu_2",
+                "name": "text_editor_code_execution",
+            },
+        },
+        {
+            "type": "content_block_delta",
+            "index": 0,
+            "delta": {
+                "type": "input_json_delta",
+                "partial_json": (
+                    '{"command": "create", "path": "new_file.txt", '
+                    '"file_text": "hi"}'
+                ),
+            },
+        },
+        {"type": "content_block_stop", "index": 0},
+        {
+            "type": "content_block_start",
+            "index": 1,
+            "content_block": {
+                "type": "text_editor_code_execution_tool_result",
+                "tool_use_id": "srvtoolu_2",
+                "content": {
+                    "type": "text_editor_code_execution_result",
+                    "is_file_update": False,
+                },
+            },
+        },
+        {"type": "content_block_stop", "index": 1},
+        {"type": "message_stop"},
+    ]
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(
+            200,
+            content = _anthropic_sse(sse_events),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        return await _collect(
+            client._stream_anthropic(
+                messages = [{"role": "user", "content": "write a file"}],
+                model = "claude-opus-4-7",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = 4096,
+                enabled_tools = ["code_execution"],
+            )
+        )
+
+    lines = _drive(run())
+    events = _tool_events(lines)
+
+    assert len(events) == 2
+    start, end = events
+    assert start["arguments"]["kind"] == "text_editor"
+    assert start["arguments"]["command"] == "create"
+    assert start["arguments"]["path"] == "new_file.txt"
+    assert end["result"] == "Created"
+
+
+def test_code_execution_error_renders_error_code(monkeypatch):
+    sse_events = [
+        {"type": "message_start", "message": {"usage": {}}},
+        {
+            "type": "content_block_start",
+            "index": 0,
+            "content_block": {
+                "type": "server_tool_use",
+                "id": "srvtoolu_3",
+                "name": "bash_code_execution",
+            },
+        },
+        {
+            "type": "content_block_delta",
+            "index": 0,
+            "delta": {
+                "type": "input_json_delta",
+                "partial_json": '{"command": "echo broken"}',
+            },
+        },
+        {"type": "content_block_stop", "index": 0},
+        {
+            "type": "content_block_start",
+            "index": 1,
+            "content_block": {
+                "type": "bash_code_execution_tool_result",
+                "tool_use_id": "srvtoolu_3",
+                "content": {
+                    "type": "bash_code_execution_tool_result_error",
+                    "error_code": "container_expired",
+                },
+            },
+        },
+        {"type": "content_block_stop", "index": 1},
+        {"type": "message_stop"},
+    ]
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(
+            200,
+            content = _anthropic_sse(sse_events),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        return await _collect(
+            client._stream_anthropic(
+                messages = [{"role": "user", "content": "run it"}],
+                model = "claude-opus-4-7",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = 4096,
+                enabled_tools = ["code_execution"],
+            )
+        )
+
+    lines = _drive(run())
+    events = _tool_events(lines)
+
+    assert len(events) == 2
+    end = events[1]
+    assert end["type"] == "tool_end"
+    assert end["result"] == "Error: container_expired"
diff --git a/studio/backend/tests/test_detect_mmproj_file.py b/studio/backend/tests/test_detect_mmproj_file.py
new file mode 100644
index 0000000000..cdb73448be
--- /dev/null
+++ b/studio/backend/tests/test_detect_mmproj_file.py
@@ -0,0 +1,326 @@
+# 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 :func:`utils.models.model_config.detect_mmproj_file` (#5347)."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import struct
+
+from utils.models.model_config import (
+    _detect_family_token,
+    detect_mmproj_file,
+    mmproj_matches_model_family,
+)
+
+
+_GGUF_MAGIC = 0x46554747
+
+
+def _gguf_with_general(path: Path, fields: dict) -> Path:
+    """Write a minimal GGUF with only ``general.*`` string KVs."""
+    body = b""
+    for k, v in fields.items():
+        kb = k.encode("utf-8")
+        vb = v.encode("utf-8")
+        body += struct.pack(" Path:
+    path.parent.mkdir(parents = True, exist_ok = True)
+    path.write_bytes(b"")
+    return path
+
+
+def test_returns_none_when_no_mmproj(tmp_path: Path):
+    model = _touch(tmp_path / "Qwen3.5-9B-Q4_K_M.gguf")
+    assert detect_mmproj_file(str(model)) is None
+
+
+def test_single_matching_family_mmproj_picked(tmp_path: Path):
+    """Single same-family projector: returned (historical behaviour)."""
+    model = _touch(tmp_path / "Qwen3.5-9B-Q4_K_M.gguf")
+    mmproj = _touch(tmp_path / "Qwen3.5-9B-BF16-mmproj.gguf")
+    assert detect_mmproj_file(str(model)) == str(mmproj.resolve())
+
+
+def test_hf_style_unprefixed_mmproj_still_works(tmp_path: Path):
+    """HF convention: weight + ``mmproj-F16.gguf`` sibling."""
+    model = _touch(tmp_path / "model.gguf")
+    mmproj = _touch(tmp_path / "mmproj-F16.gguf")
+    assert detect_mmproj_file(str(model)) == str(mmproj.resolve())
+
+
+def test_blocks_single_cross_family_projector(tmp_path: Path):
+    """#5347 core: Qwen weight + lone Gemma mmproj returns None."""
+    model = _touch(tmp_path / "Qwen3.5-9B-Q4_K_M.gguf")
+    _touch(tmp_path / "gemma-4-26B-A4B-it.mmproj-q8_0.gguf")
+    assert detect_mmproj_file(str(model)) is None
+
+
+def test_picks_matching_family_among_mixed_candidates(tmp_path: Path):
+    """Mixed Qwen + Gemma projectors: pick Qwen, drop Gemma."""
+    model = _touch(tmp_path / "Qwen3.5-9B-Q4_K_M.gguf")
+    qwen_mm = _touch(tmp_path / "Qwen3.5-9B-BF16-mmproj.gguf")
+    _touch(tmp_path / "gemma-4-26B-A4B-it.mmproj-q8_0.gguf")
+    assert detect_mmproj_file(str(model)) == str(qwen_mm.resolve())
+
+
+def test_prefers_longest_prefix_within_same_family(tmp_path: Path):
+    """Same family, different sizes: longest shared stem prefix wins."""
+    model = _touch(tmp_path / "Qwen3.5-35B-A3B-UD-Q4_K_L.gguf")
+    _touch(tmp_path / "Qwen3.5-9B-BF16-mmproj.gguf")
+    big_mm = _touch(tmp_path / "Qwen3.5-35B-A3B-BF16-mmproj.gguf")
+    assert detect_mmproj_file(str(model)) == str(big_mm.resolve())
+
+
+def test_unrecognised_family_does_not_break_detection(tmp_path: Path):
+    """Unknown model family must not return None on a sole candidate."""
+    model = _touch(tmp_path / "MyCustomBrand-7B-Q4_K_M.gguf")
+    mmproj = _touch(tmp_path / "MyCustomBrand-7B-BF16-mmproj.gguf")
+    assert detect_mmproj_file(str(model)) == str(mmproj.resolve())
+
+
+def test_directory_path_returns_first_candidate(tmp_path: Path):
+    """Directory path: no model stem to compare; legacy first-candidate."""
+    _touch(tmp_path / "Qwen3.5-9B-BF16-mmproj.gguf")
+    _touch(tmp_path / "gemma-4-26B-A4B-it.mmproj-q8_0.gguf")
+    result = detect_mmproj_file(str(tmp_path))
+    assert result is not None
+    assert "mmproj" in Path(result).name.lower()
+
+
+def test_search_root_walk_still_works(tmp_path: Path):
+    """Snapshot layout: weight in quant subdir, mmproj at snapshot root."""
+    snapshot = tmp_path / "snapshot"
+    weight = _touch(snapshot / "BF16" / "Qwen3.5-9B-BF16.gguf")
+    mmproj = _touch(snapshot / "Qwen3.5-9B-BF16-mmproj.gguf")
+    result = detect_mmproj_file(str(weight), search_root = str(snapshot))
+    assert result == str(mmproj.resolve())
+
+
+# -- Family token detection: word-bounded matching ----------------------
+
+
+def test_family_token_phi_does_not_match_sapphire():
+    """``phi`` substring inside ``sapphire`` must not tag Phi."""
+    assert _detect_family_token("sapphire-7b-q4_k_m.gguf") is None
+
+
+def test_family_token_yi_does_not_match_tinyish_names():
+    """``yi`` must not cross letter boundaries (``yip``)."""
+    assert _detect_family_token("yip-7b.gguf") is None
+    assert _detect_family_token("yi-vl-6b.gguf") == "yi"
+
+
+def test_family_token_mimo_does_not_match_mimosa():
+    """``mimo`` must not tag ``mimosa``."""
+    assert _detect_family_token("mimosa-rosa-7b.gguf") is None
+    assert _detect_family_token("MiMo-VL-7B-RL-BF16.gguf") == "mimo"
+
+
+def test_family_token_mistral_does_not_match_ministral():
+    """Pin Mistral-derivative tagging."""
+    assert _detect_family_token("Ministral-3-8B-Instruct-2512-BF16.gguf") == "ministral"
+    assert _detect_family_token("Mistral-7B-Instruct-v0.3.gguf") == "mistral"
+    assert _detect_family_token("Magistral-Small-2506-BF16.gguf") == "magistral"
+    assert (
+        _detect_family_token("Devstral-Small-2-24B-Instruct-2512-BF16.gguf")
+        == "devstral"
+    )
+
+
+def test_family_token_picks_leftmost_when_multiple_present():
+    """Leftmost family token wins, not tuple order."""
+    assert _detect_family_token("llama-phi-merge.gguf") == "llama"
+    assert _detect_family_token("phi-llama-merge.gguf") == "phi"
+    assert _detect_family_token("llama3-3b-instruct.gguf") == "llama"
+
+
+def test_family_token_new_families_recognised():
+    """Catalogue-audit additions tag correctly."""
+    assert _detect_family_token("NVIDIA-Nemotron-3-Nano-Omni-30B.gguf") == "nemotron"
+    assert _detect_family_token("Kimi-K2.6-BF16.gguf") == "kimi"
+    assert _detect_family_token("Nanonets-OCR-s-BF16.gguf") == "nanonets"
+    assert _detect_family_token("Cosmos-Reason1-7B-BF16.gguf") == "cosmos"
+    assert _detect_family_token("Apriel-1.5-15b-Thinker-BF16.gguf") == "apriel"
+    assert _detect_family_token("LFM2.5-VL-1.6B-BF16.gguf") == "lfm"
+
+
+# -- Cross-family rejection with the expanded token list ----------------
+
+
+def test_blocks_cross_family_for_new_token_pair(tmp_path: Path):
+    """Nemotron weight + lone Gemma projector returns None."""
+    model = _touch(
+        tmp_path / "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-MXFP4_MOE.gguf"
+    )
+    _touch(tmp_path / "gemma-4-26B-A4B-it.mmproj-q8_0.gguf")
+    assert detect_mmproj_file(str(model)) is None
+
+
+def test_picks_devstral_mmproj_in_mixed_dir(tmp_path: Path):
+    """Devstral weight + Devstral mmproj + a Qwen mmproj: pick Devstral."""
+    model = _touch(tmp_path / "Devstral-Small-2-24B-Instruct-2512-BF16.gguf")
+    dev_mm = _touch(tmp_path / "Devstral-Small-2-mmproj-bf16.gguf")
+    _touch(tmp_path / "Qwen3.5-9B-BF16-mmproj.gguf")
+    assert detect_mmproj_file(str(model)) == str(dev_mm.resolve())
+
+
+# -- Launcher-level family guard ----------------------------------------
+
+
+def test_mmproj_family_guard_blocks_cross_family():
+    assert (
+        mmproj_matches_model_family(
+            "/models/Qwen3.5-9B-Q4_K_M.gguf",
+            "/models/gemma-4-26B-A4B-it.mmproj-q8_0.gguf",
+        )
+        is False
+    )
+
+
+def test_mmproj_family_guard_allows_same_family():
+    assert (
+        mmproj_matches_model_family(
+            "/models/Qwen3.5-9B-Q4_K_M.gguf",
+            "/models/Qwen3.5-9B-BF16-mmproj.gguf",
+        )
+        is True
+    )
+
+
+def test_mmproj_family_guard_allows_generic_hf_mmproj():
+    """No family token on the projector: wildcard."""
+    assert (
+        mmproj_matches_model_family(
+            "/models/Qwen3.5-9B-Q4_K_M.gguf",
+            "/models/mmproj-F16.gguf",
+        )
+        is True
+    )
+
+
+def test_mmproj_family_guard_allows_unrecognised_model_family():
+    """No family token on the model: wildcard."""
+    assert (
+        mmproj_matches_model_family(
+            "/models/Apriel-1.5-15b-Thinker-BF16.gguf",
+            "/models/mmproj-F16.gguf",
+        )
+        is True
+    )
+
+
+# -- Metadata-primary pairing in detect_mmproj_file ---------------------
+
+
+def test_metadata_url_match_picked_over_filename_lookalike(tmp_path: Path):
+    """URL match beats a longer-prefix sibling."""
+    weight = _gguf_with_general(
+        tmp_path / "Qwen3.5-9B-Q4_K_M.gguf",
+        {
+            "general.architecture": "qwen2vl",
+            "general.type": "model",
+            "general.basename": "Qwen3.5",
+            "general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
+        },
+    )
+    # Closer filename prefix, wrong upstream.
+    _gguf_with_general(
+        tmp_path / "Qwen3.5-9B-mmproj-bf16.gguf",
+        {
+            "general.architecture": "clip",
+            "general.type": "mmproj",
+            "general.basename": "Qwen3.5",
+            "general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-1.5B",
+        },
+    )
+    # Matching upstream.
+    correct = _gguf_with_general(
+        tmp_path / "mmproj-BF16.gguf",
+        {
+            "general.architecture": "clip",
+            "general.type": "mmproj",
+            "general.basename": "Qwen3.5",
+            "general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
+        },
+    )
+    assert detect_mmproj_file(str(weight)) == str(correct.resolve())
+
+
+def test_metadata_url_mismatch_dropped(tmp_path: Path):
+    """Filenames match family but metadata disagrees: returns None."""
+    weight = _gguf_with_general(
+        tmp_path / "qwen-9b.gguf",
+        {
+            "general.architecture": "qwen2vl",
+            "general.type": "model",
+            "general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
+        },
+    )
+    _gguf_with_general(
+        tmp_path / "qwen-9b-mmproj.gguf",
+        {
+            "general.architecture": "clip",
+            "general.type": "mmproj",
+            "general.base_model.0.repo_url": "https://huggingface.co/google/gemma-3-9B",
+        },
+    )
+    assert detect_mmproj_file(str(weight)) is None
+
+
+def test_metadata_identifies_mmproj_without_filename_hint(tmp_path: Path):
+    """Projector named ``vision-projector.gguf`` discovered via header."""
+    weight = _gguf_with_general(
+        tmp_path / "Qwen3.5-9B.gguf",
+        {
+            "general.architecture": "qwen2vl",
+            "general.type": "model",
+            "general.basename": "Qwen3.5",
+            "general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
+        },
+    )
+    projector = _gguf_with_general(
+        tmp_path / "vision-projector.gguf",
+        {
+            "general.architecture": "clip",
+            "general.type": "mmproj",
+            "general.basename": "Qwen3.5",
+            "general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
+        },
+    )
+    assert detect_mmproj_file(str(weight)) == str(projector.resolve())
+
+
+def test_metadata_score_outranks_filename_prefix(tmp_path: Path):
+    """Score 100 (URL match) beats score 0 (long filename prefix)."""
+    weight = _gguf_with_general(
+        tmp_path / "Qwen3.5-9B-Q4_K_M.gguf",
+        {
+            "general.architecture": "qwen2vl",
+            "general.type": "model",
+            "general.basename": "Qwen3.5",
+            "general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
+        },
+    )
+    # Headerless: long shared stem, score 0.
+    _touch(tmp_path / "Qwen3.5-9B-Q4_K_M-mmproj.gguf")
+    # Headered: generic name, score 100.
+    correct = _gguf_with_general(
+        tmp_path / "mmproj-BF16.gguf",
+        {
+            "general.architecture": "clip",
+            "general.type": "mmproj",
+            "general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
+        },
+    )
+    assert detect_mmproj_file(str(weight)) == str(correct.resolve())
diff --git a/studio/backend/tests/test_gguf_metadata.py b/studio/backend/tests/test_gguf_metadata.py
new file mode 100644
index 0000000000..cf1a17347f
--- /dev/null
+++ b/studio/backend/tests/test_gguf_metadata.py
@@ -0,0 +1,216 @@
+# 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 :mod:`utils.models.gguf_metadata`. Synthesise small GGUF
+headers in tmp dirs so we never depend on real model files."""
+
+from __future__ import annotations
+
+import struct
+from pathlib import Path
+from typing import Iterable, Mapping
+
+from utils.models.gguf_metadata import (
+    is_mmproj_by_metadata,
+    pairing_score,
+    read_gguf_general_metadata,
+)
+
+
+_GGUF_MAGIC = 0x46554747
+_VTYPE_STRING = 8
+_VTYPE_UINT32 = 4
+_VTYPE_ARRAY = 9
+
+
+def _enc_string(s: str) -> bytes:
+    b = s.encode("utf-8")
+    return struct.pack(" bytes:
+    return _enc_string(key) + struct.pack(" bytes:
+    return (
+        _enc_string(key) + struct.pack(" bytes:
+    vals = list(values)
+    out = _enc_string(key) + struct.pack(" Path:
+    """Minimal GGUF: header + KV body, no tensors."""
+    extra_uint32 = extra_uint32 or {}
+    extra_string_arrays = extra_string_arrays or {}
+    kv_count = len(general_strings) + len(extra_uint32) + len(extra_string_arrays)
+    body = b""
+    for k, v in general_strings.items():
+        body += _enc_kv_string(k, v)
+    for k, v in extra_uint32.items():
+        body += _enc_kv_uint32(k, v)
+    for k, v in extra_string_arrays.items():
+        body += _enc_kv_string_array(k, v)
+    header = struct.pack(
+        " ExternalProviderClient:
+    return ExternalProviderClient(
+        provider_type = "openai",
+        base_url = base_url,
+        api_key = "sk-test",
+    )
+
+
+def _openai_sse(events: list[dict]) -> bytes:
+    chunks: list[str] = []
+    for event in events:
+        chunks.append(f"event: {event['type']}")
+        chunks.append(f"data: {json.dumps(event)}")
+        chunks.append("")
+    return ("\n".join(chunks) + "\n").encode("utf-8")
+
+
+def _tool_events(lines: list[str]) -> list[dict]:
+    out: list[dict] = []
+    for line in lines:
+        if not line.startswith("data:"):
+            continue
+        raw = line[len("data:") :].strip()
+        if not raw or raw == "[DONE]":
+            continue
+        try:
+            parsed = json.loads(raw)
+        except json.JSONDecodeError:
+            continue
+        if isinstance(parsed, dict) and "_toolEvent" in parsed:
+            out.append(parsed["_toolEvent"])
+    return out
+
+
+def test_shell_tool_added_on_cloud_with_container_auto(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        return httpx.Response(
+            200,
+            content = _openai_sse([{"type": "response.completed", "response": {}}]),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        async for _ in client._stream_openai_responses(
+            messages = [{"role": "user", "content": "compute 2+2"}],
+            model = "gpt-5.5",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = 4096,
+            enable_thinking = None,
+            reasoning_effort = None,
+            enabled_tools = ["code_execution"],
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    tools = captured["body"].get("tools") or []
+    assert {
+        "type": "shell",
+        "environment": {"type": "container_auto"},
+    } in tools
+
+
+def test_shell_tool_uses_container_reference_when_id_supplied(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        return httpx.Response(
+            200,
+            content = _openai_sse([{"type": "response.completed", "response": {}}]),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        async for _ in client._stream_openai_responses(
+            messages = [{"role": "user", "content": "what did i write earlier"}],
+            model = "gpt-5.5",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = 4096,
+            enable_thinking = None,
+            reasoning_effort = None,
+            enabled_tools = ["code_execution"],
+            openai_code_exec_container_id = "cntr_abc123",
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    tools = captured["body"].get("tools") or []
+    assert {
+        "type": "shell",
+        "environment": {
+            "type": "container_reference",
+            "container_id": "cntr_abc123",
+        },
+    } in tools
+
+
+def test_shell_tool_refused_for_non_cloud_base_url(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        return httpx.Response(
+            200,
+            content = _openai_sse([{"type": "response.completed", "response": {}}]),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client(base_url = "http://localhost:11434/v1")
+        async for _ in client._stream_openai_responses(
+            messages = [{"role": "user", "content": "hi"}],
+            model = "gpt-5.5",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = 4096,
+            enable_thinking = None,
+            reasoning_effort = None,
+            enabled_tools = ["code_execution"],
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    tools = captured["body"].get("tools") or []
+    # Shell tool must NOT leak to local OpenAI-compat servers — those
+    # 400 on the unknown tool type.
+    assert all(t.get("type") != "shell" for t in tools)
+
+
+def test_shell_call_emits_tool_start_and_end(monkeypatch):
+    sse_events = [
+        {
+            "type": "response.output_item.added",
+            "item": {
+                "type": "shell_call",
+                "id": "scall_1",
+                "action": {"commands": ["ls -la"]},
+            },
+        },
+        {
+            "type": "response.output_item.done",
+            "item": {
+                "type": "shell_call",
+                "id": "scall_1",
+                "action": {"commands": ["ls -la"]},
+                "status": "completed",
+            },
+        },
+        {
+            "type": "response.output_item.done",
+            "item": {
+                "type": "shell_call_output",
+                "id": "scout_1",
+                "call_id": "scall_1",
+                "output": [
+                    {
+                        "stdout": "total 24\ndrwxr-xr-x .",
+                        "stderr": "",
+                        "outcome": {"type": "exit", "exit_code": 0},
+                    }
+                ],
+            },
+        },
+        {"type": "response.completed", "response": {}},
+    ]
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(
+            200,
+            content = _openai_sse(sse_events),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        return await _collect(
+            client._stream_openai_responses(
+                messages = [{"role": "user", "content": "list files"}],
+                model = "gpt-5.5",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = 4096,
+                enable_thinking = None,
+                reasoning_effort = None,
+                enabled_tools = ["code_execution"],
+            )
+        )
+
+    lines = _drive(run())
+    events = _tool_events(lines)
+    starts = [e for e in events if e["type"] == "tool_start"]
+    ends = [e for e in events if e["type"] == "tool_end"]
+    assert len(starts) == 1
+    assert len(ends) == 1
+    assert starts[0]["tool_name"] == "code_execution"
+    assert starts[0]["tool_call_id"] == "scall_1"
+    assert starts[0]["arguments"] == {"kind": "bash", "command": "ls -la"}
+    assert ends[0]["tool_call_id"] == "scall_1"
+    assert "total 24" in ends[0]["result"]
+
+
+def test_container_ready_emitted_when_new_id_surfaces(monkeypatch):
+    sse_events = [
+        {
+            "type": "response.completed",
+            "response": {"container_id": "cntr_new_456"},
+        },
+    ]
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(
+            200,
+            content = _openai_sse(sse_events),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        return await _collect(
+            client._stream_openai_responses(
+                messages = [{"role": "user", "content": "do stuff"}],
+                model = "gpt-5.5",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = 4096,
+                enable_thinking = None,
+                reasoning_effort = None,
+                enabled_tools = ["code_execution"],
+            )
+        )
+
+    lines = _drive(run())
+    events = _tool_events(lines)
+    ready = [e for e in events if e["type"] == "container_ready"]
+    assert len(ready) == 1
+    assert ready[0]["container_id"] == "cntr_new_456"
+
+
+def test_container_ready_not_emitted_when_id_unchanged(monkeypatch):
+    sse_events = [
+        {
+            "type": "response.completed",
+            "response": {"container_id": "cntr_same_789"},
+        },
+    ]
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(
+            200,
+            content = _openai_sse(sse_events),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        return await _collect(
+            client._stream_openai_responses(
+                messages = [{"role": "user", "content": "do stuff"}],
+                model = "gpt-5.5",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = 4096,
+                enable_thinking = None,
+                reasoning_effort = None,
+                enabled_tools = ["code_execution"],
+                openai_code_exec_container_id = "cntr_same_789",
+            )
+        )
+
+    lines = _drive(run())
+    events = _tool_events(lines)
+    # No churn — id matches the one already on the thread record.
+    assert not any(e["type"] == "container_ready" for e in events)
+
+
+def test_stale_container_emits_invalidated(monkeypatch):
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(
+            400,
+            content = json.dumps(
+                {
+                    "error": {
+                        "message": "container has expired",
+                        "type": "invalid_request_error",
+                    }
+                }
+            ).encode("utf-8"),
+            headers = {"content-type": "application/json"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        return await _collect(
+            client._stream_openai_responses(
+                messages = [{"role": "user", "content": "hi"}],
+                model = "gpt-5.5",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = 4096,
+                enable_thinking = None,
+                reasoning_effort = None,
+                enabled_tools = ["code_execution"],
+                openai_code_exec_container_id = "cntr_stale_999",
+            )
+        )
+
+    lines = _drive(run())
+    events = _tool_events(lines)
+    invalidated = [e for e in events if e["type"] == "container_invalidated"]
+    assert len(invalidated) == 1
diff --git a/studio/backend/tests/test_openai_container_crud.py b/studio/backend/tests/test_openai_container_crud.py
new file mode 100644
index 0000000000..161a6fab83
--- /dev/null
+++ b/studio/backend/tests/test_openai_container_crud.py
@@ -0,0 +1,201 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Unit tests for the /v1/containers CRUD client methods.
+
+Covers:
+- All three calls (list / create / delete) send
+  ``OpenAI-Beta: containers=v1``. Without it, OpenAI silently no-ops
+  the DELETE while still returning 200 ``{"deleted": true}``.
+- ``delete_openai_container`` raises when the response body does not
+  report ``{"deleted": true}``, even on a 2xx response.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+
+import httpx
+import pytest
+
+from core.inference import external_provider as ep_mod
+from core.inference.external_provider import ExternalProviderClient
+
+
+def _drive(coro):
+    return asyncio.new_event_loop().run_until_complete(coro)
+
+
+def _mock_http_client(monkeypatch, handler):
+    """Wire `handler` for both the shared `_http_client` AND any
+    per-call `httpx.AsyncClient(...)` instances. delete_openai_container
+    intentionally creates a fresh AsyncClient (see comment in
+    external_provider.delete_openai_container) so the test must
+    also intercept that constructor."""
+    transport = httpx.MockTransport(handler)
+    monkeypatch.setattr(ep_mod, "_http_client", httpx.AsyncClient(transport = transport))
+    real_async_client = httpx.AsyncClient
+
+    def _patched_async_client(*args, **kwargs):
+        kwargs["transport"] = transport
+        return real_async_client(*args, **kwargs)
+
+    monkeypatch.setattr(ep_mod.httpx, "AsyncClient", _patched_async_client)
+
+
+def _make_client() -> ExternalProviderClient:
+    return ExternalProviderClient(
+        provider_type = "openai",
+        base_url = "https://api.openai.com/v1",
+        api_key = "sk-test",
+    )
+
+
+def test_list_sends_openai_beta_header(monkeypatch):
+    seen: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        seen["headers"] = dict(request.headers)
+        seen["url"] = str(request.url)
+        return httpx.Response(
+            200,
+            json = {"data": [{"id": "cntr_x", "name": "auto"}]},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+    result = _drive(_make_client().list_openai_containers())
+
+    assert result == [{"id": "cntr_x", "name": "auto"}]
+    assert seen["headers"].get("openai-beta") == "containers=v1"
+    assert seen["url"] == "https://api.openai.com/v1/containers"
+
+
+def test_create_sends_openai_beta_header(monkeypatch):
+    seen: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        seen["headers"] = dict(request.headers)
+        seen["body"] = json.loads(request.content.decode("utf-8"))
+        return httpx.Response(200, json = {"id": "cntr_new", "name": "analysis"})
+
+    _mock_http_client(monkeypatch, handler)
+    result = _drive(
+        _make_client().create_openai_container(name = "analysis", ttl_minutes = 30)
+    )
+
+    assert result == {"id": "cntr_new", "name": "analysis"}
+    assert seen["headers"].get("openai-beta") == "containers=v1"
+    assert seen["body"]["name"] == "analysis"
+    assert seen["body"]["expires_after"] == {
+        "anchor": "last_active_at",
+        "minutes": 30,
+    }
+
+
+def test_delete_sends_openai_beta_header_and_accepts_confirmation(monkeypatch):
+    seen: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        seen["headers"] = dict(request.headers)
+        seen["url"] = str(request.url)
+        seen["method"] = request.method
+        return httpx.Response(
+            200,
+            json = {"id": "cntr_x", "object": "container.deleted", "deleted": True},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+    _drive(_make_client().delete_openai_container("cntr_x"))
+
+    assert seen["method"] == "DELETE"
+    assert seen["url"] == "https://api.openai.com/v1/containers/cntr_x"
+    assert seen["headers"].get("openai-beta") == "containers=v1"
+
+
+def test_delete_raises_when_response_lacks_deleted_true(monkeypatch):
+    """OpenAI returns 200 ``{"deleted": true}`` even when the request is
+    silently rejected (e.g. before we started sending OpenAI-Beta).
+    Defensive guard: when the body omits ``deleted: true``, surface it
+    as an error so the UI can report the failure instead of falsely
+    reporting success."""
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        # 200 but no deleted flag — simulate an unexpected payload shape.
+        return httpx.Response(200, json = {"id": "cntr_x", "object": "container"})
+
+    _mock_http_client(monkeypatch, handler)
+
+    with pytest.raises(httpx.HTTPError, match = "did not confirm container deletion"):
+        _drive(_make_client().delete_openai_container("cntr_x"))
+
+
+def test_delete_raises_when_deleted_is_false(monkeypatch):
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(
+            200,
+            json = {"id": "cntr_x", "object": "container.deleted", "deleted": False},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    with pytest.raises(httpx.HTTPError, match = "did not confirm container deletion"):
+        _drive(_make_client().delete_openai_container("cntr_x"))
+
+
+def test_delete_raises_when_body_is_not_json(monkeypatch):
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(200, content = b"OK")
+
+    _mock_http_client(monkeypatch, handler)
+
+    with pytest.raises(httpx.HTTPError, match = "did not confirm container deletion"):
+        _drive(_make_client().delete_openai_container("cntr_x"))
+
+
+def test_delete_propagates_openai_4xx(monkeypatch):
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(404, json = {"error": {"message": "not found"}})
+
+    _mock_http_client(monkeypatch, handler)
+
+    with pytest.raises(httpx.HTTPStatusError):
+        _drive(_make_client().delete_openai_container("cntr_missing"))
+
+
+def test_list_route_filters_expired_containers(monkeypatch):
+    """OpenAI keeps containers in /v1/containers indefinitely with
+    status="expired" after their idle TTL passes — they can't be
+    used but still show up. The list route must drop them so the
+    picker only surfaces usable containers."""
+    from routes import inference as inf_mod
+    from models.inference import OpenAIContainerRequest
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(
+            200,
+            json = {
+                "data": [
+                    {"id": "cntr_active", "name": "live", "status": "running"},
+                    {"id": "cntr_dead", "name": "old", "status": "expired"},
+                    {"id": "cntr_unknown", "name": "no-status"},
+                ],
+            },
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    def fake_resolve(_body):
+        return _make_client()
+
+    monkeypatch.setattr(inf_mod, "_resolve_openai_cloud_client", fake_resolve)
+
+    body = OpenAIContainerRequest(
+        encrypted_api_key = "enc",
+        provider_base_url = "https://api.openai.com/v1",
+    )
+    response = _drive(inf_mod.list_openai_containers(body, current_subject = "u"))
+    ids = [c.id for c in response.containers]
+    assert "cntr_active" in ids
+    assert "cntr_unknown" in ids  # missing status is treated as usable
+    assert "cntr_dead" not in ids
diff --git a/studio/backend/tests/test_openai_responses_translation.py b/studio/backend/tests/test_openai_responses_translation.py
index 4ad6a19ea9..22ccba7058 100644
--- a/studio/backend/tests/test_openai_responses_translation.py
+++ b/studio/backend/tests/test_openai_responses_translation.py
@@ -286,6 +286,68 @@ def test_responses_reasoning_effort_included_when_requested(monkeypatch):
     assert captured["body"]["reasoning"] == {"effort": "high", "summary": "auto"}
 
 
+def test_responses_reasoning_summary_omitted_for_o3(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        return httpx.Response(
+            200,
+            content = _responses_sse([{"type": "response.completed", "response": {}}]),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        async for _ in client._stream_openai_responses(
+            messages = [{"role": "user", "content": "hi"}],
+            model = "o3",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = None,
+            enable_thinking = None,
+            reasoning_effort = "high",
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+    assert captured["body"]["reasoning"] == {"effort": "high"}
+
+
+def test_responses_reasoning_summary_omitted_for_o3_with_enable_thinking(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        return httpx.Response(
+            200,
+            content = _responses_sse([{"type": "response.completed", "response": {}}]),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        async for _ in client._stream_openai_responses(
+            messages = [{"role": "user", "content": "hi"}],
+            model = "o3",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = None,
+            enable_thinking = True,
+            reasoning_effort = None,
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+    assert captured["body"]["reasoning"] == {"effort": "medium"}
+
+
 def test_responses_reasoning_effort_none_omits_summary(monkeypatch):
     captured: dict = {}
 
diff --git a/studio/backend/tests/test_training_raw_support.py b/studio/backend/tests/test_training_raw_support.py
index 876ee34686..384247a191 100644
--- a/studio/backend/tests/test_training_raw_support.py
+++ b/studio/backend/tests/test_training_raw_support.py
@@ -70,6 +70,48 @@ class TestTrainingRawSupport(unittest.TestCase):
         self.assertTrue(config["load_in_4bit"])
         self.assertEqual(config["embedding_learning_rate"], 1e-5)
 
+    def test_training_backend_forwards_grad_clipping_controls(self):
+        backend = TrainingBackend()
+
+        class DummyProcess:
+            pid = 12345
+
+            def start(self):
+                return None
+
+        class DummyThread:
+            def start(self):
+                return None
+
+        dummy_queue = object()
+
+        with (
+            patch(
+                "core.training.training.prepare_gpu_selection",
+                return_value = ([0], {"selection_mode": "auto"}),
+            ),
+            patch(
+                "core.training.training._CTX.Queue",
+                side_effect = [dummy_queue, dummy_queue],
+            ),
+            patch(
+                "core.training.training._CTX.Process", return_value = DummyProcess()
+            ) as mock_process,
+            patch(
+                "core.training.training.threading.Thread",
+                return_value = DummyThread(),
+            ),
+        ):
+            backend.start_training(
+                job_id = "test-grad-clip",
+                model_name = "unsloth/test",
+                training_type = "LoRA/QLoRA",
+                max_grad_norm = 0.7,
+            )
+
+        config = mock_process.call_args.kwargs["kwargs"]["config"]
+        self.assertEqual(config["max_grad_norm"], 0.7)
+
     def test_training_route_forwards_embedding_learning_rate(self):
         training_route = _load_route_module(
             "training_route_module_raw_support",
diff --git a/studio/backend/tests/test_training_worker_flash_attn.py b/studio/backend/tests/test_training_worker_flash_attn.py
index 41a7c87df1..0737bdc82f 100644
--- a/studio/backend/tests/test_training_worker_flash_attn.py
+++ b/studio/backend/tests/test_training_worker_flash_attn.py
@@ -37,6 +37,7 @@ def test_runtime_flash_attn_prefers_prebuilt_wheel(monkeypatch):
     statuses: list[str] = []
 
     monkeypatch.delenv(worker._FLASH_ATTN_SKIP_ENV, raising = False)
+    monkeypatch.setattr(worker, "has_blackwell_gpu", lambda: False)
     monkeypatch.setattr(builtins, "__import__", _missing_flash_attn_import())
     monkeypatch.setattr(
         worker,
@@ -65,6 +66,7 @@ def test_runtime_flash_attn_falls_back_to_pypi(monkeypatch):
     statuses: list[str] = []
 
     monkeypatch.delenv(worker._FLASH_ATTN_SKIP_ENV, raising = False)
+    monkeypatch.setattr(worker, "has_blackwell_gpu", lambda: False)
     monkeypatch.setattr(builtins, "__import__", _missing_flash_attn_import())
     monkeypatch.setattr(
         worker,
@@ -112,6 +114,29 @@ def test_runtime_flash_attn_skip_env_avoids_all_install_work(monkeypatch):
     worker._sp.run.assert_not_called()
 
 
+def test_runtime_flash_attn_skips_on_blackwell(monkeypatch):
+    statuses: list[str] = []
+    install_mock = mock.Mock()
+
+    monkeypatch.delenv(worker._FLASH_ATTN_SKIP_ENV, raising = False)
+    monkeypatch.setattr(
+        worker, "_should_try_runtime_flash_attn_install", lambda max_seq: True
+    )
+    monkeypatch.setattr(worker, "has_blackwell_gpu", lambda: True)
+    monkeypatch.setattr(worker, "_install_package_wheel_first", install_mock)
+    monkeypatch.setattr(
+        worker,
+        "_send_status",
+        lambda queue, message: statuses.append(message),
+    )
+
+    worker._ensure_flash_attn_for_long_context(event_queue = [], max_seq_length = 65536)
+
+    install_mock.assert_not_called()
+    assert len(statuses) == 1
+    assert "Blackwell" in statuses[0]
+
+
 def test_causal_conv1d_fast_path_preserves_wheel_first_install_args(monkeypatch):
     install_mock = mock.Mock(return_value = True)
     monkeypatch.setattr(worker, "_install_package_wheel_first", install_mock)
diff --git a/studio/backend/utils/datasets/chat_templates.py b/studio/backend/utils/datasets/chat_templates.py
index 35fbaba8f0..cfdd811853 100644
--- a/studio/backend/utils/datasets/chat_templates.py
+++ b/studio/backend/utils/datasets/chat_templates.py
@@ -28,6 +28,23 @@ DEFAULT_ALPACA_TEMPLATE = """Below is an instruction that describes a task, pair
 {}"""
 
 
+def _is_mlx_runtime() -> bool:
+    try:
+        from unsloth_zoo.mlx import is_mlx_available
+    except ImportError:
+        return False
+    return is_mlx_available()
+
+
+def _chat_template_kwargs() -> dict:
+    if not _is_mlx_runtime():
+        return {}
+    return {
+        "patch_saving": False,
+        "use_zoo_tokenizer_patch": True,
+    }
+
+
 def get_tokenizer_chat_template(tokenizer, model_name):
     """
     Gets appropriate chat template for tokenizer based on model.
@@ -60,6 +77,7 @@ def get_tokenizer_chat_template(tokenizer, model_name):
             tokenizer = get_chat_template(
                 tokenizer,
                 chat_template = matched_template,
+                **_chat_template_kwargs(),
             )
         except Exception as e:
             logger.info(f"⚠️ Failed to apply Unsloth template '{matched_template}': {e}")
@@ -79,6 +97,7 @@ def get_tokenizer_chat_template(tokenizer, model_name):
                 tokenizer = get_chat_template(
                     tokenizer,
                     chat_template = "chatml",
+                    **_chat_template_kwargs(),
                 )
             except Exception as e:
                 logger.info(f"⚠️ Failed to apply default ChatML template: {e}")
@@ -255,7 +274,11 @@ def apply_chat_template_to_dataset(
         if not (hasattr(tokenizer, 'chat_template') and tokenizer.chat_template):
             try:
                 from unsloth.chat_templates import get_chat_template
-                tokenizer = get_chat_template(tokenizer, chat_template = "alpaca")
+                tokenizer = get_chat_template(
+                    tokenizer,
+                    chat_template = "alpaca",
+                    **_chat_template_kwargs(),
+                )
                 logger.info(f"📝 Set alpaca chat template on tokenizer for model saving")
             except Exception as e:
                 logger.info(f"⚠️ Could not set alpaca template on tokenizer: {e}")
diff --git a/studio/backend/utils/models/gguf_metadata.py b/studio/backend/utils/models/gguf_metadata.py
new file mode 100644
index 0000000000..5629bac58b
--- /dev/null
+++ b/studio/backend/utils/models/gguf_metadata.py
@@ -0,0 +1,236 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Free-function ``general.*`` reader for GGUF headers, used by
+``detect_mmproj_file`` to pair weights and projectors via
+``general.base_model.0.repo_url``. ~30 ms per file, cached by
+(path, mtime, size)."""
+
+from __future__ import annotations
+
+import os
+import struct
+import threading
+from pathlib import Path
+from typing import Dict, Optional, Tuple
+
+from loggers import get_logger
+
+logger = get_logger(__name__)
+
+
+_GGUF_MAGIC = 0x46554747  # b"GGUF" LE u32
+
+_WANTED_GENERAL_KEYS: frozenset[str] = frozenset(
+    {
+        "general.architecture",
+        "general.type",
+        "general.name",
+        "general.basename",
+        "general.organization",
+        "general.size_label",
+        "general.finetune",
+        "general.base_model.0.name",
+        "general.base_model.0.organization",
+        "general.base_model.0.repo_url",
+        "general.repo_url",
+        "general.source.url",
+        "general.source.repo_url",
+        "general.source.huggingface.repository",
+    }
+)
+
+
+# Cache failed parses too so a broken file is not retried each scan.
+_CacheKey = Tuple[str, int, int]
+_METADATA_CACHE: Dict[_CacheKey, Optional[Dict[str, str]]] = {}
+_CACHE_LOCK = threading.Lock()
+_CACHE_MAX_ENTRIES = 4096
+
+
+def _cache_key(path: str) -> Optional[_CacheKey]:
+    try:
+        st = os.stat(path)
+    except OSError:
+        return None
+    try:
+        resolved = str(Path(path).resolve())
+    except OSError:
+        resolved = str(path)
+    return (resolved, st.st_mtime_ns, st.st_size)
+
+
+def read_gguf_general_metadata(path: str) -> Optional[Dict[str, str]]:
+    """Return ``general.*`` strings from a GGUF header, or ``None`` if
+    the file is missing, unreadable, or not a GGUF. ``{}`` means the
+    file is valid but carries none of the wanted keys."""
+    key = _cache_key(path)
+    if key is None:
+        return None
+    with _CACHE_LOCK:
+        if key in _METADATA_CACHE:
+            return _METADATA_CACHE[key]
+    result = _parse_gguf_header(path)
+    with _CACHE_LOCK:
+        # Arbitrary eviction; header reads are cheap so true LRU is overkill.
+        while len(_METADATA_CACHE) >= _CACHE_MAX_ENTRIES:
+            try:
+                _METADATA_CACHE.pop(next(iter(_METADATA_CACHE)))
+            except StopIteration:
+                break
+        _METADATA_CACHE[key] = result
+    return result
+
+
+def _parse_gguf_header(path: str) -> Optional[Dict[str, str]]:
+    out: Dict[str, str] = {}
+    try:
+        with open(path, "rb") as f:
+            head = f.read(24)
+            if len(head) < 24:
+                return None
+            magic, _version, _tcount, kv_count = struct.unpack(" 1 << 20:  # 1 MB sanity bound
+                        break
+                    kbytes = f.read(klen)
+                    if len(kbytes) < klen:
+                        break
+                    key = kbytes.decode("utf-8", "replace")
+                    vt_bytes = f.read(4)
+                    if len(vt_bytes) < 4:
+                        break
+                    vtype = struct.unpack(" 1 << 22:  # 4 MB sanity bound
+                            break
+                        sbytes = f.read(slen)
+                        if len(sbytes) < slen:
+                            break
+                        out[key] = sbytes.decode("utf-8", "replace")
+                    else:
+                        if not _skip_gguf_value(f, vtype):
+                            break
+                except (struct.error, UnicodeDecodeError):
+                    break
+    except OSError as e:
+        logger.debug(f"read_gguf_general_metadata: cannot open {path}: {e}")
+        return None
+    except Exception as e:
+        logger.debug(f"read_gguf_general_metadata: parse failure on {path}: {e}")
+        return None
+    return out
+
+
+# Strings (8) and arrays (9) are handled inline.
+_FIXED_VTYPE_SIZES: Dict[int, int] = {
+    0: 1,  # uint8
+    1: 1,  # int8
+    2: 2,  # uint16
+    3: 2,  # int16
+    4: 4,  # uint32
+    5: 4,  # int32
+    6: 4,  # float32
+    7: 1,  # bool
+    10: 8,  # uint64
+    11: 8,  # int64
+    12: 8,  # float64
+}
+
+
+def _skip_gguf_value(f, vtype: int) -> bool:
+    """Advance past one GGUF value. ``f.seek(.., 1)`` past EOF is legal
+    on a regular file so truncation is detected on the next read; we
+    only return False for unknown types or sanity-bound overflow."""
+    if vtype == 8:  # STRING
+        slen_bytes = f.read(8)
+        if len(slen_bytes) < 8:
+            return False
+        slen = struct.unpack(" 1 << 30:  # 1 GB sanity bound
+            return False
+        f.seek(slen, 1)
+        return True
+    if vtype == 9:  # ARRAY
+        head = f.read(12)
+        if len(head) < 12:
+            return False
+        atype, alen = struct.unpack(" 1 << 30:
+            return False
+        if atype == 8:
+            for _ in range(alen):
+                slen_bytes = f.read(8)
+                if len(slen_bytes) < 8:
+                    return False
+                slen = struct.unpack(" 1 << 30:
+                    return False
+                f.seek(slen, 1)
+            return True
+        sz = _FIXED_VTYPE_SIZES.get(atype)
+        if sz is None:
+            return False
+        f.seek(sz * alen, 1)
+        return True
+    sz = _FIXED_VTYPE_SIZES.get(vtype)
+    if sz is None:
+        return False
+    f.seek(sz, 1)
+    return True
+
+
+def is_mmproj_by_metadata(meta: Optional[Dict[str, str]]) -> Optional[bool]:
+    """True/False from ``general.type``; None means fall back to filename."""
+    if not meta:
+        return None
+    t = meta.get("general.type")
+    if t is None:
+        return None
+    return t.lower() == "mmproj"
+
+
+def pairing_score(
+    weight_meta: Optional[Dict[str, str]],
+    mmproj_meta: Optional[Dict[str, str]],
+) -> int:
+    """Pairing confidence: 100 = base_model URL match, 80 = basename + org,
+    60 = basename, -1 = definitive mismatch, 0 = decide from filename."""
+    if not weight_meta or not mmproj_meta:
+        return 0
+
+    w_url = weight_meta.get("general.base_model.0.repo_url")
+    p_url = mmproj_meta.get("general.base_model.0.repo_url")
+    if w_url and p_url:
+        return 100 if w_url.strip().rstrip("/") == p_url.strip().rstrip("/") else -1
+
+    w_base = weight_meta.get("general.basename")
+    p_base = mmproj_meta.get("general.basename")
+    w_org = weight_meta.get("general.base_model.0.organization") or weight_meta.get(
+        "general.organization"
+    )
+    p_org = mmproj_meta.get("general.base_model.0.organization") or mmproj_meta.get(
+        "general.organization"
+    )
+    if w_base and p_base and w_org and p_org:
+        if w_base.lower() == p_base.lower() and w_org.lower() == p_org.lower():
+            return 80
+        return -1
+
+    if w_base and p_base:
+        return 60 if w_base.lower() == p_base.lower() else -1
+
+    return 0
diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py
index ebf85c5320..bf7f7a009b 100644
--- a/studio/backend/utils/models/model_config.py
+++ b/studio/backend/utils/models/model_config.py
@@ -19,6 +19,11 @@ from utils.paths import (
     resolve_export_dir,
 )
 from utils.utils import without_hf_auth
+from utils.models.gguf_metadata import (
+    is_mmproj_by_metadata,
+    pairing_score,
+    read_gguf_general_metadata,
+)
 import structlog
 from loggers import get_logger
 import os
@@ -801,12 +806,15 @@ _AUDIO_TOKEN_PATTERNS = {
     "whisper": lambda tokens: "<|startoftranscript|>" in tokens,
     "audio_vlm": lambda tokens: "" in tokens,
     "bicodec": lambda tokens: any(t.startswith("<|bicodec_") for t in tokens),
-    "dac": lambda tokens: "<|audio_start|>" in tokens
-    and "<|audio_end|>" in tokens
-    and "<|text_start|>" in tokens
-    and "<|text_end|>" in tokens,
-    "snac": lambda tokens: sum(1 for t in tokens if t.startswith(" 10000,
+    "dac": lambda tokens: (
+        "<|audio_start|>" in tokens
+        and "<|audio_end|>" in tokens
+        and "<|text_start|>" in tokens
+        and "<|text_end|>" in tokens
+    ),
+    "snac": lambda tokens: (
+        sum(1 for t in tokens if t.startswith(" 10000
+    ),
 }
 
 
@@ -913,6 +921,85 @@ def _is_mmproj(filename: str) -> bool:
     return "mmproj" in filename.lower()
 
 
+# Family tokens for #5347's filename fallback. Lowercase. Order does not
+# matter (see ``_detect_family_token``).
+_MODEL_FAMILY_TOKENS: tuple[str, ...] = (
+    "qwen",
+    "gemma",
+    "llama",
+    "mistral",
+    "ministral",
+    "magistral",
+    "devstral",
+    "phi",
+    "deepseek",
+    "internvl",
+    "minicpm",
+    "llava",
+    "glm",
+    "yi",
+    "command-r",
+    "molmo",
+    "pixtral",
+    "smolvlm",
+    "moondream",
+    "granite",
+    "ovis",
+    "nemotron",
+    "kimi",
+    "nanonets",
+    "cosmos",
+    "mimo",
+    "apriel",
+    "lfm",
+)
+
+
+# Word-bounded match: any letter on either side disqualifies. Stops
+# ``phi`` matching ``sapphire``, ``yi`` matching ``tiny``, etc.
+_FAMILY_TOKEN_RE_CACHE: Dict[str, "_re.Pattern[str]"] = {}
+
+
+def _family_token_re(token: str) -> "_re.Pattern[str]":
+    pat = _FAMILY_TOKEN_RE_CACHE.get(token)
+    if pat is None:
+        pat = _re.compile(rf"(?:^|[^a-z])({_re.escape(token)})(?:[^a-z]|$)")
+        _FAMILY_TOKEN_RE_CACHE[token] = pat
+    return pat
+
+
+def _detect_family_token(filename: str) -> Optional[str]:
+    """Leftmost-position match; ties prefer the longer token."""
+    name = filename.lower()
+    best: Optional[tuple[int, int, str]] = None  # (start, -len, token)
+    for token in _MODEL_FAMILY_TOKENS:
+        m = _family_token_re(token).search(name)
+        if m is None:
+            continue
+        key = (m.start(1), -len(token), token)
+        if best is None or key < best:
+            best = key
+    return None if best is None else best[2]
+
+
+def mmproj_matches_model_family(model_path: str, mmproj_path: str) -> bool:
+    """Defense-in-depth guard for the launcher: True unless both filenames
+    carry recognised family tokens that disagree."""
+    model_fam = _detect_family_token(Path(model_path).name)
+    mmproj_fam = _detect_family_token(Path(mmproj_path).name)
+    if model_fam is None or mmproj_fam is None:
+        return True
+    return model_fam == mmproj_fam
+
+
+def _shared_prefix_len(a: str, b: str) -> int:
+    n = min(len(a), len(b))
+    for i in range(n):
+        if a[i] != b[i]:
+            return i
+    return n
+
+
 def _is_gguf_filename(filename: str) -> bool:
     return filename.lower().endswith(".gguf")
 
@@ -927,33 +1014,18 @@ def _iter_gguf_files(directory: Path, recursive: bool = False):
 
 
 def detect_mmproj_file(path: str, search_root: Optional[str] = None) -> Optional[str]:
-    """
-    Find the mmproj (vision projection) GGUF file for a given model.
+    """Find the mmproj GGUF for a model.
 
-    Args:
-        path: Directory to search — or a .gguf file (uses its parent dir
-            as the starting point).
-        search_root: Optional outer directory that should also be scanned
-            (and any directory between it and ``path``). This handles
-            local layouts where the model weights live in a quant-named
-            subdir (``snapshot/BF16/foo.gguf``) but the mmproj sits at
-            the snapshot root (``snapshot/mmproj-BF16.gguf``). When
-            ``None``, only the immediate parent dir is scanned, matching
-            the historical behavior.
-
-    Returns:
-        Full path to the mmproj .gguf file, or None if not found.
-    """
+    ``path``: directory or a .gguf file. ``search_root``: optional ancestor
+    to also walk (snapshot layouts where the weight is in ``snapshot/BF16/``
+    but the projector sits at ``snapshot/``). Returns the projector path or
+    ``None``."""
     p = Path(path)
     start_dir = p.parent if p.is_file() else p
     if not start_dir.is_dir():
         return None
 
-    # Build the list of dirs to scan: immediate dir first, then walk up
-    # to (and including) ``search_root`` if it is an ancestor. We walk
-    # incrementally rather than recursing into ``search_root`` so we
-    # don't accidentally pick up an mmproj from a sibling subdir
-    # belonging to a different model variant.
+    # Walk incrementally so a sibling subdir's mmproj cannot leak in.
     seen: set[Path] = set()
     scan_order: list[Path] = []
 
@@ -969,12 +1041,7 @@ def detect_mmproj_file(path: str, search_root: Optional[str] = None) -> Optional
 
     _add(start_dir)
 
-    # When ``path`` is a symlink (e.g. Ollama's ``.studio_links/...gguf``
-    # -> ``blobs/sha256-...``), the symlink's parent directory rarely
-    # contains the mmproj sibling; the real mmproj file lives next to
-    # the symlink target. Add the target's parent to the scan so vision
-    # GGUFs that are surfaced via symlinks are still recognised as
-    # vision models.
+    # Ollama's .studio_links/foo.gguf -> blobs/sha256-...: also scan target dir.
     try:
         if p.is_symlink() and p.is_file():
             target_parent = p.resolve().parent
@@ -986,14 +1053,12 @@ def detect_mmproj_file(path: str, search_root: Optional[str] = None) -> Optional
         try:
             root_resolved = Path(search_root).resolve()
             start_resolved = start_dir.resolve()
-            # Only walk if start_dir is inside (or equal to) search_root.
             if root_resolved == start_resolved or (
                 start_resolved.is_relative_to(root_resolved)
                 if hasattr(start_resolved, "is_relative_to")
                 else str(start_resolved).startswith(str(root_resolved) + "/")
             ):
                 cur = start_resolved
-                # Walk up from start_dir to (and including) root_resolved.
                 while cur != root_resolved and cur.parent != cur:
                     cur = cur.parent
                     _add(cur)
@@ -1002,11 +1067,66 @@ def detect_mmproj_file(path: str, search_root: Optional[str] = None) -> Optional
         except OSError:
             pass
 
+    candidates: list[Path] = []
+    seen_resolved: set[Path] = set()
     for d in scan_order:
         for f in _iter_gguf_files(d):
-            if _is_mmproj(f.name):
-                return str(f.resolve())
-    return None
+            try:
+                resolved = f.resolve()
+            except OSError:
+                continue
+            if resolved in seen_resolved:
+                continue
+            # Prefer ``general.type=='mmproj'``; fall back to filename.
+            meta = read_gguf_general_metadata(str(resolved))
+            by_meta = is_mmproj_by_metadata(meta)
+            if by_meta is True or (by_meta is None and _is_mmproj(f.name)):
+                seen_resolved.add(resolved)
+                candidates.append(resolved)
+
+    if not candidates:
+        return None
+
+    # Directory path: no model name to compare against; legacy behaviour.
+    if not p.is_file():
+        return str(candidates[0])
+
+    # Stage 1: GGUF metadata. Stage 2: filename family token (#5347).
+    model_stem = p.stem.lower()
+    model_family = _detect_family_token(p.name)
+    weight_meta = read_gguf_general_metadata(str(p))
+
+    scored: list[tuple[int, Path]] = []
+    for c in candidates:
+        cand_meta = read_gguf_general_metadata(str(c))
+        meta_score = pairing_score(weight_meta, cand_meta)
+        if meta_score == -1:
+            logger.info(f"detect_mmproj_file: dropped {c.name} (metadata mismatch)")
+            continue
+        if meta_score == 0 and model_family is not None:
+            # Unrecognised candidate family is a wildcard (``mmproj-F16.gguf``).
+            cand_family = _detect_family_token(c.name)
+            if cand_family is not None and cand_family != model_family:
+                logger.info(
+                    f"detect_mmproj_file: dropped {c.name} "
+                    f"(filename family {cand_family!r} vs model {model_family!r})"
+                )
+                continue
+        scored.append((meta_score, c))
+
+    if not scored:
+        return None
+
+    # Score first, then longest shared prefix, then shorter stem.
+    best = max(
+        scored,
+        key = lambda sc: (
+            sc[0],
+            _shared_prefix_len(model_stem, sc[1].stem.lower()),
+            -len(sc[1].stem),
+        ),
+    )
+    return str(best[1])
 
 
 def detect_gguf_model(path: str) -> Optional[str]:
@@ -1360,7 +1480,7 @@ def detect_gguf_model_remote(
             if attempt < 2:
                 time.sleep(2**attempt)
     logger.warning(
-        f"Could not check GGUF files for '{repo_id}' after 3 attempts: " f"{last_err}"
+        f"Could not check GGUF files for '{repo_id}' after 3 attempts: {last_err}"
     )
     return None
 
diff --git a/studio/backend/utils/wheel_utils.py b/studio/backend/utils/wheel_utils.py
index 3ed9bda827..5c42e890d1 100644
--- a/studio/backend/utils/wheel_utils.py
+++ b/studio/backend/utils/wheel_utils.py
@@ -3,6 +3,7 @@
 
 from __future__ import annotations
 
+import functools
 import json
 import logging
 import platform
@@ -22,6 +23,49 @@ FLASH_ATTN_RELEASE_BASE_URL = (
 )
 
 
+@functools.lru_cache(maxsize = 1)
+def has_blackwell_gpu() -> bool:
+    """Return True if any visible NVIDIA GPU has compute capability >= 10.0
+    (Blackwell: sm_100, sm_120, sm_121, ...).
+
+    Dao-AILab does not publish prebuilt flash-attention wheels for these
+    architectures, and the older-arch wheels fail to load on Blackwell, so
+    callers use this gate to skip the flash-attn install/upgrade path.
+
+    Result is cached for the process lifetime since GPU hardware does not
+    change. Tests that mock subprocess/nvidia-smi must call
+    ``has_blackwell_gpu.cache_clear()`` before each invocation.
+    """
+    exe = shutil.which("nvidia-smi")
+    if not exe:
+        return False
+    try:
+        result = subprocess.run(
+            [exe, "--query-gpu=compute_cap", "--format=csv,noheader"],
+            stdout = subprocess.PIPE,
+            stderr = subprocess.DEVNULL,
+            text = True,
+            timeout = 10,
+            env = child_env_without_native_path_secret(),
+        )
+    except (OSError, subprocess.TimeoutExpired):
+        return False
+    if result.returncode != 0:
+        return False
+    for line in result.stdout.splitlines():
+        cap = line.strip()
+        if not cap:
+            continue
+        major_part = cap.split(".", 1)[0]
+        try:
+            major = int(major_part)
+        except ValueError:
+            continue
+        if major >= 10:
+            return True
+    return False
+
+
 def linux_wheel_platform_tag() -> str | None:
     machine = platform.machine().lower()
     if sys.platform.startswith("linux"):
diff --git a/studio/frontend/package-lock.json b/studio/frontend/package-lock.json
index 464f47c09c..0525b984a1 100644
--- a/studio/frontend/package-lock.json
+++ b/studio/frontend/package-lock.json
@@ -10,8 +10,6 @@
       "dependencies": {
         "@assistant-ui/core": "0.1.17",
         "@assistant-ui/react": "0.12.28",
-        "@assistant-ui/react-markdown": "0.12.11",
-        "@assistant-ui/react-streamdown": "0.1.11",
         "@assistant-ui/tap": "0.5.10",
         "@base-ui/react": "^1.2.0",
         "@dagrejs/dagre": "^2.0.4",
@@ -22,18 +20,16 @@
         "@hugeicons/core-free-icons": "^4.1.1",
         "@hugeicons/react": "^1.1.5",
         "@huggingface/hub": "^2.9.0",
-        "@langchain/core": "^1.1.27",
         "@radix-ui/react-checkbox": "^1.3.3",
         "@radix-ui/react-label": "^2.1.8",
         "@radix-ui/react-select": "^2.2.6",
         "@radix-ui/react-separator": "^1.1.8",
         "@radix-ui/react-slot": "^1.2.4",
-        "@streamdown/cjk": "1.0.3",
         "@streamdown/code": "1.1.1",
         "@streamdown/math": "1.0.2",
         "@streamdown/mermaid": "1.0.2",
         "@tailwindcss/vite": "^4.2.2",
-        "@tanstack/react-router": "^1.159.10",
+        "@tanstack/react-router": "1.169.2",
         "@tanstack/react-table": "^8.21.3",
         "@tauri-apps/api": "^2.10.1",
         "@tauri-apps/plugin-clipboard-manager": "^2.3.2",
@@ -42,21 +38,18 @@
         "@tauri-apps/plugin-process": "^2.3.1",
         "@tauri-apps/plugin-updater": "^2.10.1",
         "@toolwind/corner-shape": "^0.0.8-3",
-        "@types/canvas-confetti": "^1.9.0",
         "@xyflow/react": "^12.10.0",
         "assistant-stream": "0.3.12",
         "canvas-confetti": "^1.9.4",
         "class-variance-authority": "^0.7.1",
         "clsx": "^2.1.1",
         "cmdk": "^1.1.1",
-        "date-fns": "^4.1.0",
         "dexie": "^4.3.0",
         "js-yaml": "^4.1.1",
         "katex": "^0.16.28",
         "lucide-react": "^1.7.0",
         "mammoth": "^1.11.0",
         "motion": "^12.34.0",
-        "next": "^16.1.6",
         "next-themes": "^0.4.6",
         "node-forge": "^1.4.0",
         "radix-ui": "^1.4.3",
@@ -65,7 +58,6 @@
         "react-dom": "^19.2.4",
         "react-resizable-panels": "^4.6.4",
         "recharts": "3.7.0",
-        "remark-gfm": "^4.0.1",
         "shadcn": "^4.2.0",
         "sonner": "^2.0.7",
         "streamdown": "2.5.0",
@@ -79,6 +71,7 @@
       "devDependencies": {
         "@biomejs/biome": "^1.9.4",
         "@eslint/js": "^9.39.1",
+        "@types/canvas-confetti": "^1.9.0",
         "@types/js-yaml": "^4.0.9",
         "@types/node": "^25.5.2",
         "@types/node-forge": "^1.3.14",
@@ -89,7 +82,6 @@
         "eslint-plugin-react-hooks": "^7.0.1",
         "eslint-plugin-react-refresh": "^0.5.2",
         "globals": "^17.4.0",
-        "playwright": "^1.59.1",
         "typescript": "~5.9.3",
         "typescript-eslint": "^8.55.0",
         "vite": "^8.0.1"
@@ -181,66 +173,6 @@
         }
       }
     },
-    "node_modules/@assistant-ui/react-markdown": {
-      "version": "0.12.11",
-      "resolved": "https://registry.npmjs.org/@assistant-ui/react-markdown/-/react-markdown-0.12.11.tgz",
-      "integrity": "sha512-gYu4XVI2lX3lp9UG7V5VWP1+eO7SZomiBKsAZOKUOeuwn/hoL+J0vFY52FUgJixdF2R8NPPto2lb98DmJE70lA==",
-      "license": "MIT",
-      "dependencies": {
-        "@radix-ui/react-primitive": "^2.1.4",
-        "@radix-ui/react-use-callback-ref": "^1.1.1",
-        "classnames": "^2.5.1",
-        "react-markdown": "^10.1.0"
-      },
-      "peerDependencies": {
-        "@assistant-ui/react": "^0.12.26",
-        "@types/react": "*",
-        "react": "^18 || ^19"
-      },
-      "peerDependenciesMeta": {
-        "@types/react": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@assistant-ui/react-streamdown": {
-      "version": "0.1.11",
-      "resolved": "https://registry.npmjs.org/@assistant-ui/react-streamdown/-/react-streamdown-0.1.11.tgz",
-      "integrity": "sha512-9y+89ZxotYSt81hChSVjK2kwUYRKq7UW/r5qoqZTpcb7119gc0NOj0dx9xxuyXE2QfR6EY8rW6yBz3g+Y7RrhQ==",
-      "license": "MIT",
-      "dependencies": {
-        "rehype-harden": "^1.1.8",
-        "rehype-raw": "^7.0.0",
-        "rehype-sanitize": "^6.0.0",
-        "streamdown": "^2.5.0"
-      },
-      "peerDependencies": {
-        "@assistant-ui/react": "^0.12.26",
-        "@streamdown/cjk": "^1.0.0",
-        "@streamdown/code": "^1.0.0",
-        "@streamdown/math": "^1.0.0",
-        "@streamdown/mermaid": "^1.0.0",
-        "@types/react": "*",
-        "react": "^18 || ^19"
-      },
-      "peerDependenciesMeta": {
-        "@streamdown/cjk": {
-          "optional": true
-        },
-        "@streamdown/code": {
-          "optional": true
-        },
-        "@streamdown/math": {
-          "optional": true
-        },
-        "@streamdown/mermaid": {
-          "optional": true
-        },
-        "@types/react": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/@assistant-ui/store": {
       "version": "0.2.9",
       "resolved": "https://registry.npmjs.org/@assistant-ui/store/-/store-0.2.9.tgz",
@@ -922,12 +854,6 @@
       "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==",
       "license": "MIT"
     },
-    "node_modules/@cfworker/json-schema": {
-      "version": "4.1.1",
-      "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
-      "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==",
-      "license": "MIT"
-    },
     "node_modules/@chevrotain/cst-dts-gen": {
       "version": "12.0.0",
       "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz",
@@ -1542,472 +1468,6 @@
         "mlly": "^1.8.2"
       }
     },
-    "node_modules/@img/colour": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
-      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
-      "license": "MIT",
-      "optional": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@img/sharp-darwin-arm64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
-      "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-darwin-arm64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-darwin-x64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
-      "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-darwin-x64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-libvips-darwin-arm64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
-      "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-darwin-x64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
-      "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-arm": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
-      "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
-      "cpu": [
-        "arm"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-arm64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
-      "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-ppc64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
-      "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
-      "cpu": [
-        "ppc64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-riscv64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
-      "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
-      "cpu": [
-        "riscv64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-s390x": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
-      "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
-      "cpu": [
-        "s390x"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-x64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
-      "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
-      "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
-      "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-linux-arm": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
-      "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
-      "cpu": [
-        "arm"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-arm": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linux-arm64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
-      "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-arm64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linux-ppc64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
-      "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
-      "cpu": [
-        "ppc64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-ppc64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linux-riscv64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
-      "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
-      "cpu": [
-        "riscv64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-riscv64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linux-s390x": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
-      "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
-      "cpu": [
-        "s390x"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-s390x": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linux-x64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
-      "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-x64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linuxmusl-arm64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
-      "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linuxmusl-x64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
-      "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-wasm32": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
-      "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
-      "cpu": [
-        "wasm32"
-      ],
-      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
-      "optional": true,
-      "dependencies": {
-        "@emnapi/runtime": "^1.7.0"
-      },
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-win32-arm64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
-      "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "Apache-2.0 AND LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-win32-ia32": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
-      "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
-      "cpu": [
-        "ia32"
-      ],
-      "license": "Apache-2.0 AND LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-win32-x64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
-      "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "Apache-2.0 AND LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
     "node_modules/@inquirer/ansi": {
       "version": "2.0.5",
       "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz",
@@ -2135,27 +1595,6 @@
         "@jridgewell/sourcemap-codec": "^1.4.14"
       }
     },
-    "node_modules/@langchain/core": {
-      "version": "1.1.44",
-      "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.44.tgz",
-      "integrity": "sha512-RePW1IjGCHr9ua2vcby3aE8mOOz3EnwDZxMEGbNDT91kf14eqkJqxDXvaZFviGdcN9DTrxM5RPQNAHmwSm4tbg==",
-      "license": "MIT",
-      "dependencies": {
-        "@cfworker/json-schema": "^4.0.2",
-        "@standard-schema/spec": "^1.1.0",
-        "ansi-styles": "^5.0.0",
-        "camelcase": "6",
-        "decamelize": "1.2.0",
-        "js-tiktoken": "^1.0.12",
-        "langsmith": ">=0.5.0 <1.0.0",
-        "mustache": "^4.2.0",
-        "p-queue": "^6.6.2",
-        "zod": "^3.25.76 || ^4"
-      },
-      "engines": {
-        "node": ">=20"
-      }
-    },
     "node_modules/@mermaid-js/parser": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz",
@@ -2268,140 +1707,6 @@
         "@emnapi/runtime": "^1.7.1"
       }
     },
-    "node_modules/@next/env": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz",
-      "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==",
-      "license": "MIT"
-    },
-    "node_modules/@next/swc-darwin-arm64": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz",
-      "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/@next/swc-darwin-x64": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz",
-      "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/@next/swc-linux-arm64-gnu": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz",
-      "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/@next/swc-linux-arm64-musl": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz",
-      "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/@next/swc-linux-x64-gnu": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz",
-      "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/@next/swc-linux-x64-musl": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz",
-      "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/@next/swc-win32-arm64-msvc": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz",
-      "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/@next/swc-win32-x64-msvc": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
-      "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">= 10"
-      }
-    },
     "node_modules/@noble/ciphers": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
@@ -6400,20 +5705,6 @@
       "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
       "license": "MIT"
     },
-    "node_modules/@streamdown/cjk": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@streamdown/cjk/-/cjk-1.0.3.tgz",
-      "integrity": "sha512-WRg8HR/gHbBoTgsMd91OKFUClIoDcEFVofJvluvEAyjx3KpU0aGgD9tGDqHkHj14ShoMSkX0IYetWGegTcwIJw==",
-      "license": "Apache-2.0",
-      "dependencies": {
-        "remark-cjk-friendly": "^2.0.1",
-        "remark-cjk-friendly-gfm-strikethrough": "^2.0.1",
-        "unist-util-visit": "^5.0.0"
-      },
-      "peerDependencies": {
-        "react": "^18.0.0 || ^19.0.0"
-      }
-    },
     "node_modules/@streamdown/code": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/@streamdown/code/-/code-1.1.1.tgz",
@@ -6452,15 +5743,6 @@
         "react": "^18.0.0 || ^19.0.0"
       }
     },
-    "node_modules/@swc/helpers": {
-      "version": "0.5.15",
-      "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
-      "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
-      "license": "Apache-2.0",
-      "dependencies": {
-        "tslib": "^2.8.0"
-      }
-    },
     "node_modules/@tabby_ai/hijri-converter": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz",
@@ -7041,6 +6323,7 @@
       "version": "1.9.0",
       "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
       "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==",
+      "dev": true,
       "license": "MIT"
     },
     "node_modules/@types/d3": {
@@ -7393,6 +6676,7 @@
       "version": "19.2.14",
       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
       "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+      "devOptional": true,
       "license": "MIT",
       "dependencies": {
         "csstype": "^3.2.2"
@@ -7966,18 +7250,6 @@
         "url": "https://github.com/chalk/ansi-regex?sponsor=1"
       }
     },
-    "node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
     "node_modules/argparse": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -8241,18 +7513,6 @@
         "node": ">=6"
       }
     },
-    "node_modules/camelcase": {
-      "version": "6.3.0",
-      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
-      "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/caniuse-lite": {
       "version": "1.0.30001791",
       "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
@@ -8412,12 +7672,6 @@
       "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
       "license": "MIT"
     },
-    "node_modules/classnames": {
-      "version": "2.5.1",
-      "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
-      "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
-      "license": "MIT"
-    },
     "node_modules/cli-cursor": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
@@ -8467,12 +7721,6 @@
         "node": ">= 12"
       }
     },
-    "node_modules/client-only": {
-      "version": "0.0.1",
-      "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
-      "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
-      "license": "MIT"
-    },
     "node_modules/cliui": {
       "version": "8.0.1",
       "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -8729,6 +7977,7 @@
       "version": "3.2.3",
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
       "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "devOptional": true,
       "license": "MIT"
     },
     "node_modules/cytoscape": {
@@ -9287,15 +8536,6 @@
         }
       }
     },
-    "node_modules/decamelize": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
-      "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
     "node_modules/decimal.js-light": {
       "version": "2.5.1",
       "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
@@ -9876,12 +9116,6 @@
         "node": ">= 0.6"
       }
     },
-    "node_modules/eventemitter3": {
-      "version": "4.0.7",
-      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
-      "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
-      "license": "MIT"
-    },
     "node_modules/eventsource": {
       "version": "3.0.7",
       "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@@ -10303,21 +9537,6 @@
         "node": ">=14.14"
       }
     },
-    "node_modules/fsevents": {
-      "version": "2.3.2",
-      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
-      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
-      "dev": true,
-      "hasInstallScript": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
-      }
-    },
     "node_modules/function-bind": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -11252,15 +10471,6 @@
         "url": "https://github.com/sponsors/panva"
       }
     },
-    "node_modules/js-tiktoken": {
-      "version": "1.0.21",
-      "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz",
-      "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==",
-      "license": "MIT",
-      "dependencies": {
-        "base64-js": "^1.5.1"
-      }
-    },
     "node_modules/js-tokens": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -11418,39 +10628,6 @@
         "npm": ">=10.2.3"
       }
     },
-    "node_modules/langsmith": {
-      "version": "0.6.1",
-      "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.6.1.tgz",
-      "integrity": "sha512-qNBNPRFqScIlGaPGfMrxhw/GTOL4GJKMp1P4jeA3xuI+Gkj5Ei3wvOtxpYaZMTeBbXTW3yi4n4Wf3nCgogvttg==",
-      "license": "MIT",
-      "dependencies": {
-        "p-queue": "6.6.2"
-      },
-      "peerDependencies": {
-        "@opentelemetry/api": "*",
-        "@opentelemetry/exporter-trace-otlp-proto": "*",
-        "@opentelemetry/sdk-trace-base": "*",
-        "openai": "*",
-        "ws": ">=7"
-      },
-      "peerDependenciesMeta": {
-        "@opentelemetry/api": {
-          "optional": true
-        },
-        "@opentelemetry/exporter-trace-otlp-proto": {
-          "optional": true
-        },
-        "@opentelemetry/sdk-trace-base": {
-          "optional": true
-        },
-        "openai": {
-          "optional": true
-        },
-        "ws": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/layout-base": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
@@ -12351,77 +11528,6 @@
         "micromark-util-types": "^2.0.0"
       }
     },
-    "node_modules/micromark-extension-cjk-friendly": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/micromark-extension-cjk-friendly/-/micromark-extension-cjk-friendly-2.0.1.tgz",
-      "integrity": "sha512-OkzoYVTL1ChbvQ8Cc1ayTIz7paFQz8iS9oIYmewncweUSwmWR+hkJF9spJ1lxB90XldJl26A1F4IkPOKS3bDXw==",
-      "license": "MIT",
-      "dependencies": {
-        "devlop": "^1.1.0",
-        "micromark-extension-cjk-friendly-util": "3.0.1",
-        "micromark-util-chunked": "^2.0.1",
-        "micromark-util-resolve-all": "^2.0.1",
-        "micromark-util-symbol": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "peerDependencies": {
-        "micromark": "^4.0.0",
-        "micromark-util-types": "^2.0.0"
-      },
-      "peerDependenciesMeta": {
-        "micromark-util-types": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/micromark-extension-cjk-friendly-gfm-strikethrough": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/micromark-extension-cjk-friendly-gfm-strikethrough/-/micromark-extension-cjk-friendly-gfm-strikethrough-2.0.1.tgz",
-      "integrity": "sha512-wVC0zwjJNqQeX+bb07YTPu/CvSAyCTafyYb7sMhX1r62/Lw5M/df3JyYaANyp8g15c1ypJRFSsookTqA1IDsUg==",
-      "license": "MIT",
-      "dependencies": {
-        "devlop": "^1.1.0",
-        "get-east-asian-width": "^1.4.0",
-        "micromark-extension-cjk-friendly-util": "3.0.1",
-        "micromark-util-character": "^2.1.1",
-        "micromark-util-chunked": "^2.0.1",
-        "micromark-util-resolve-all": "^2.0.1",
-        "micromark-util-symbol": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "peerDependencies": {
-        "micromark": "^4.0.0",
-        "micromark-util-types": "^2.0.0"
-      },
-      "peerDependenciesMeta": {
-        "micromark-util-types": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/micromark-extension-cjk-friendly-util": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/micromark-extension-cjk-friendly-util/-/micromark-extension-cjk-friendly-util-3.0.1.tgz",
-      "integrity": "sha512-GcbXqTTHOsiZHyF753oIddP/J2eH8j9zpyQPhkof6B2JNxfEJabnQqxbCgzJNuNes0Y2jTNJ3LiYPSXr6eJA8w==",
-      "license": "MIT",
-      "dependencies": {
-        "get-east-asian-width": "^1.4.0",
-        "micromark-util-character": "^2.1.1",
-        "micromark-util-symbol": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "peerDependenciesMeta": {
-        "micromark-util-types": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/micromark-extension-gfm": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
@@ -13144,15 +12250,6 @@
         "url": "https://opencollective.com/express"
       }
     },
-    "node_modules/mustache": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
-      "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
-      "license": "MIT",
-      "bin": {
-        "mustache": "bin/mustache"
-      }
-    },
     "node_modules/mute-stream": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
@@ -13196,59 +12293,6 @@
         "node": ">= 0.6"
       }
     },
-    "node_modules/next": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz",
-      "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==",
-      "license": "MIT",
-      "dependencies": {
-        "@next/env": "16.2.4",
-        "@swc/helpers": "0.5.15",
-        "baseline-browser-mapping": "^2.9.19",
-        "caniuse-lite": "^1.0.30001579",
-        "postcss": "8.4.31",
-        "styled-jsx": "5.1.6"
-      },
-      "bin": {
-        "next": "dist/bin/next"
-      },
-      "engines": {
-        "node": ">=20.9.0"
-      },
-      "optionalDependencies": {
-        "@next/swc-darwin-arm64": "16.2.4",
-        "@next/swc-darwin-x64": "16.2.4",
-        "@next/swc-linux-arm64-gnu": "16.2.4",
-        "@next/swc-linux-arm64-musl": "16.2.4",
-        "@next/swc-linux-x64-gnu": "16.2.4",
-        "@next/swc-linux-x64-musl": "16.2.4",
-        "@next/swc-win32-arm64-msvc": "16.2.4",
-        "@next/swc-win32-x64-msvc": "16.2.4",
-        "sharp": "^0.34.5"
-      },
-      "peerDependencies": {
-        "@opentelemetry/api": "^1.1.0",
-        "@playwright/test": "^1.51.1",
-        "babel-plugin-react-compiler": "*",
-        "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
-        "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
-        "sass": "^1.3.0"
-      },
-      "peerDependenciesMeta": {
-        "@opentelemetry/api": {
-          "optional": true
-        },
-        "@playwright/test": {
-          "optional": true
-        },
-        "babel-plugin-react-compiler": {
-          "optional": true
-        },
-        "sass": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/next-themes": {
       "version": "0.4.6",
       "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
@@ -13531,15 +12575,6 @@
       "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==",
       "license": "MIT"
     },
-    "node_modules/p-finally": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
-      "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=4"
-      }
-    },
     "node_modules/p-limit": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -13572,34 +12607,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/p-queue": {
-      "version": "6.6.2",
-      "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
-      "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
-      "license": "MIT",
-      "dependencies": {
-        "eventemitter3": "^4.0.4",
-        "p-timeout": "^3.2.0"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/p-timeout": {
-      "version": "3.2.0",
-      "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
-      "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
-      "license": "MIT",
-      "dependencies": {
-        "p-finally": "^1.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/package-manager-detector": {
       "version": "1.6.0",
       "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
@@ -13790,38 +12797,6 @@
         "pathe": "^2.0.1"
       }
     },
-    "node_modules/playwright": {
-      "version": "1.59.1",
-      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
-      "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "dependencies": {
-        "playwright-core": "1.59.1"
-      },
-      "bin": {
-        "playwright": "cli.js"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "optionalDependencies": {
-        "fsevents": "2.3.2"
-      }
-    },
-    "node_modules/playwright-core": {
-      "version": "1.59.1",
-      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
-      "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "bin": {
-        "playwright-core": "cli.js"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
     "node_modules/points-on-curve": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
@@ -13838,34 +12813,6 @@
         "points-on-curve": "0.2.0"
       }
     },
-    "node_modules/postcss": {
-      "version": "8.4.31",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
-      "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
-      "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.6",
-        "picocolors": "^1.0.0",
-        "source-map-js": "^1.0.2"
-      },
-      "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",
@@ -13879,24 +12826,6 @@
         "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",
@@ -14299,33 +13228,6 @@
       "license": "MIT",
       "peer": true
     },
-    "node_modules/react-markdown": {
-      "version": "10.1.0",
-      "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
-      "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/hast": "^3.0.0",
-        "@types/mdast": "^4.0.0",
-        "devlop": "^1.0.0",
-        "hast-util-to-jsx-runtime": "^2.0.0",
-        "html-url-attributes": "^3.0.0",
-        "mdast-util-to-hast": "^13.0.0",
-        "remark-parse": "^11.0.0",
-        "remark-rehype": "^11.0.0",
-        "unified": "^11.0.0",
-        "unist-util-visit": "^5.0.0",
-        "vfile": "^6.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/unified"
-      },
-      "peerDependencies": {
-        "@types/react": ">=18",
-        "react": ">=18"
-      }
-    },
     "node_modules/react-redux": {
       "version": "9.2.0",
       "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
@@ -14608,48 +13510,6 @@
         "url": "https://opencollective.com/unified"
       }
     },
-    "node_modules/remark-cjk-friendly": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/remark-cjk-friendly/-/remark-cjk-friendly-2.0.1.tgz",
-      "integrity": "sha512-6WwkoQyZf/4j5k53zdFYrR8Ca+UVn992jXdLUSBDZR4eBpFhKyVxmA4gUHra/5fesjGIxrDhHesNr/sVoiiysA==",
-      "license": "MIT",
-      "dependencies": {
-        "micromark-extension-cjk-friendly": "2.0.1"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "peerDependencies": {
-        "@types/mdast": "^4.0.0",
-        "unified": "^11.0.0"
-      },
-      "peerDependenciesMeta": {
-        "@types/mdast": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/remark-cjk-friendly-gfm-strikethrough": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/remark-cjk-friendly-gfm-strikethrough/-/remark-cjk-friendly-gfm-strikethrough-2.0.1.tgz",
-      "integrity": "sha512-pWKj25O2eLXIL1aBupayl1fKhco+Brw8qWUWJPVB9EBzbQNd7nGLj0nLmJpggWsGLR5j5y40PIdjxby9IEYTuA==",
-      "license": "MIT",
-      "dependencies": {
-        "micromark-extension-cjk-friendly-gfm-strikethrough": "2.0.1"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "peerDependencies": {
-        "@types/mdast": "^4.0.0",
-        "unified": "^11.0.0"
-      },
-      "peerDependenciesMeta": {
-        "@types/mdast": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/remark-gfm": {
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@@ -15163,64 +14023,6 @@
         "url": "https://github.com/sponsors/colinhacks"
       }
     },
-    "node_modules/sharp": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
-      "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
-      "hasInstallScript": true,
-      "license": "Apache-2.0",
-      "optional": true,
-      "dependencies": {
-        "@img/colour": "^1.0.0",
-        "detect-libc": "^2.1.2",
-        "semver": "^7.7.3"
-      },
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-darwin-arm64": "0.34.5",
-        "@img/sharp-darwin-x64": "0.34.5",
-        "@img/sharp-libvips-darwin-arm64": "1.2.4",
-        "@img/sharp-libvips-darwin-x64": "1.2.4",
-        "@img/sharp-libvips-linux-arm": "1.2.4",
-        "@img/sharp-libvips-linux-arm64": "1.2.4",
-        "@img/sharp-libvips-linux-ppc64": "1.2.4",
-        "@img/sharp-libvips-linux-riscv64": "1.2.4",
-        "@img/sharp-libvips-linux-s390x": "1.2.4",
-        "@img/sharp-libvips-linux-x64": "1.2.4",
-        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
-        "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
-        "@img/sharp-linux-arm": "0.34.5",
-        "@img/sharp-linux-arm64": "0.34.5",
-        "@img/sharp-linux-ppc64": "0.34.5",
-        "@img/sharp-linux-riscv64": "0.34.5",
-        "@img/sharp-linux-s390x": "0.34.5",
-        "@img/sharp-linux-x64": "0.34.5",
-        "@img/sharp-linuxmusl-arm64": "0.34.5",
-        "@img/sharp-linuxmusl-x64": "0.34.5",
-        "@img/sharp-wasm32": "0.34.5",
-        "@img/sharp-win32-arm64": "0.34.5",
-        "@img/sharp-win32-ia32": "0.34.5",
-        "@img/sharp-win32-x64": "0.34.5"
-      }
-    },
-    "node_modules/sharp/node_modules/semver": {
-      "version": "7.7.4",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
-      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
-      "license": "ISC",
-      "optional": true,
-      "bin": {
-        "semver": "bin/semver.js"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
     "node_modules/shebang-command": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -15601,29 +14403,6 @@
         "inline-style-parser": "0.2.7"
       }
     },
-    "node_modules/styled-jsx": {
-      "version": "5.1.6",
-      "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
-      "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
-      "license": "MIT",
-      "dependencies": {
-        "client-only": "0.0.1"
-      },
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "peerDependencies": {
-        "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
-      },
-      "peerDependenciesMeta": {
-        "@babel/core": {
-          "optional": true
-        },
-        "babel-plugin-macros": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/stylis": {
       "version": "4.4.0",
       "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
diff --git a/studio/frontend/package.json b/studio/frontend/package.json
index c69b2fdf3e..d104aad157 100644
--- a/studio/frontend/package.json
+++ b/studio/frontend/package.json
@@ -18,8 +18,6 @@
   "dependencies": {
     "@assistant-ui/core": "0.1.17",
     "@assistant-ui/react": "0.12.28",
-    "@assistant-ui/react-markdown": "0.12.11",
-    "@assistant-ui/react-streamdown": "0.1.11",
     "@assistant-ui/tap": "0.5.10",
     "@base-ui/react": "^1.2.0",
     "@dagrejs/dagre": "^2.0.4",
@@ -30,13 +28,11 @@
     "@hugeicons/core-free-icons": "^4.1.1",
     "@hugeicons/react": "^1.1.5",
     "@huggingface/hub": "^2.9.0",
-    "@langchain/core": "^1.1.27",
     "@radix-ui/react-checkbox": "^1.3.3",
     "@radix-ui/react-label": "^2.1.8",
     "@radix-ui/react-select": "^2.2.6",
     "@radix-ui/react-separator": "^1.1.8",
     "@radix-ui/react-slot": "^1.2.4",
-    "@streamdown/cjk": "1.0.3",
     "@streamdown/code": "1.1.1",
     "@streamdown/math": "1.0.2",
     "@streamdown/mermaid": "1.0.2",
@@ -50,21 +46,18 @@
     "@tauri-apps/plugin-process": "^2.3.1",
     "@tauri-apps/plugin-updater": "^2.10.1",
     "@toolwind/corner-shape": "^0.0.8-3",
-    "@types/canvas-confetti": "^1.9.0",
     "@xyflow/react": "^12.10.0",
     "assistant-stream": "0.3.12",
     "canvas-confetti": "^1.9.4",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
     "cmdk": "^1.1.1",
-    "date-fns": "^4.1.0",
     "dexie": "^4.3.0",
     "js-yaml": "^4.1.1",
     "katex": "^0.16.28",
     "lucide-react": "^1.7.0",
     "mammoth": "^1.11.0",
     "motion": "^12.34.0",
-    "next": "^16.1.6",
     "next-themes": "^0.4.6",
     "node-forge": "^1.4.0",
     "radix-ui": "^1.4.3",
@@ -73,7 +66,6 @@
     "react-dom": "^19.2.4",
     "react-resizable-panels": "^4.6.4",
     "recharts": "3.7.0",
-    "remark-gfm": "^4.0.1",
     "shadcn": "^4.2.0",
     "sonner": "^2.0.7",
     "streamdown": "2.5.0",
@@ -92,6 +84,7 @@
   "devDependencies": {
     "@biomejs/biome": "^1.9.4",
     "@eslint/js": "^9.39.1",
+    "@types/canvas-confetti": "^1.9.0",
     "@types/js-yaml": "^4.0.9",
     "@types/node-forge": "^1.3.14",
     "@types/node": "^25.5.2",
@@ -102,7 +95,6 @@
     "eslint-plugin-react-hooks": "^7.0.1",
     "eslint-plugin-react-refresh": "^0.5.2",
     "globals": "^17.4.0",
-    "playwright": "^1.59.1",
     "typescript": "~5.9.3",
     "typescript-eslint": "^8.55.0",
     "vite": "^8.0.1"
diff --git a/studio/frontend/public/provider-logos/llama_cpp.svg b/studio/frontend/public/provider-logos/llama_cpp.svg
new file mode 100644
index 0000000000..218cc1de88
--- /dev/null
+++ b/studio/frontend/public/provider-logos/llama_cpp.svg
@@ -0,0 +1 @@
+
diff --git a/studio/frontend/public/provider-logos/ollama.svg b/studio/frontend/public/provider-logos/ollama.svg
new file mode 100644
index 0000000000..d3b6a42dd7
--- /dev/null
+++ b/studio/frontend/public/provider-logos/ollama.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/studio/frontend/public/provider-logos/vllm.svg b/studio/frontend/public/provider-logos/vllm.svg
new file mode 100644
index 0000000000..0c8a13de01
--- /dev/null
+++ b/studio/frontend/public/provider-logos/vllm.svg
@@ -0,0 +1 @@
+
diff --git a/studio/frontend/src/components/assistant-ui/model-selector.tsx b/studio/frontend/src/components/assistant-ui/model-selector.tsx
index b4f7dd08d2..dc8bffb2b7 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector.tsx
+++ b/studio/frontend/src/components/assistant-ui/model-selector.tsx
@@ -10,10 +10,12 @@ import {
 } from "@/components/ui/popover";
 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
 import { usePlatformStore } from "@/config/env";
+import { isCustomProviderType } from "@/features/chat/external-providers";
 import { cn } from "@/lib/utils";
 import {
   ArrowDown01Icon,
   CloudIcon,
+  DashboardSquare01Icon,
   FolderSearchIcon,
   Logout01Icon,
   Search01Icon,
@@ -40,6 +42,9 @@ const PROVIDER_LOGO_EXT: Record = {
   kimi: "jpg",
   qwen: "png",
   openrouter: "svg",
+  vllm: "svg",
+  ollama: "svg",
+  llama_cpp: "svg",
 };
 
 function providerLogoSrc(providerType: string | undefined): string | undefined {
@@ -59,6 +64,17 @@ function ExternalProviderLogo({
   title?: string;
 }) {
   const src = providerLogoSrc(providerType);
+  if (!src && isCustomProviderType(providerType)) {
+    return (
+      
+        
+      
+    );
+  }
+
   if (!src) return null;
   return (
      = ({ disabled }) => {
         autoFocus={!disabled}
         disabled={disabled}
         aria-label="Message input"
+        // dir="auto": browser picks LTR/RTL from the first strong char;
+        // no effect on Latin / CJK / Devanagari.
+        dir="auto"
         {...inputProps}
       />
        {
     externalSelection != null
       ? externalProviders.find((p) => p.id === externalSelection.providerId)
       : undefined;
+  const isKimiExternal = selectedExternalProvider?.providerType === "kimi";
+  const toolsEnabled = useChatRuntimeStore((s) => s.toolsEnabled);
+  const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled);
   const effectiveExternalModelId =
     selectedExternalProvider?.providerType === "openrouter" &&
     externalSelection?.modelId === "openrouter/free" &&
@@ -507,6 +514,10 @@ const ReasoningToggle: FC = () => {
       ? getExternalReasoningCapabilities(
           selectedExternalProvider?.providerType,
           effectiveExternalModelId,
+          {
+            isReasoningProvider:
+              selectedExternalProvider?.isReasoningModel === true,
+          },
         )
       : null;
   const effectiveReasoningStyle =
@@ -587,6 +598,11 @@ const ReasoningToggle: FC = () => {
                 setReasoningEffort(level);
                 setReasoningEnabled(true);
                 applyQwenThinkingParams(true);
+                // Kimi's $web_search builtin forbids thinking, so
+                // enabling thinking flips the Search pill off.
+                if (isKimiExternal && toolsEnabled) {
+                  setToolsEnabled(false);
+                }
               }}
             >
               {formatEffortLabel(level)}
@@ -613,6 +629,11 @@ const ReasoningToggle: FC = () => {
         const next = !reasoningEnabled;
         setReasoningEnabled(next);
         applyQwenThinkingParams(next);
+        // Mutual exclusion with the Search pill on Kimi — see the
+        // dropdown branch above and shared-composer for the same rule.
+        if (isKimiExternal && next && toolsEnabled) {
+          setToolsEnabled(false);
+        }
       }}
       className="composer-pill-btn"
       data-active={
@@ -680,16 +701,44 @@ const WebSearchToggle: FC = () => {
   const modelLoaded = useChatRuntimeStore(
     (s) => !!s.params.checkpoint && !s.modelLoading,
   );
+  const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
   const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
+  // External providers (OpenAI today) expose a server-side web_search tool
+  // even when the local tool runtime is unavailable — gate the Search pill
+  // on either source so it lights up on external models too. Mirror of
+  // shared-composer's searchDisabled.
+  const supportsBuiltinWebSearch = useChatRuntimeStore(
+    (s) => s.supportsBuiltinWebSearch,
+  );
   const toolsEnabled = useChatRuntimeStore((s) => s.toolsEnabled);
   const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled);
-  const disabled = !(modelLoaded && supportsTools);
+  const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
+  const externalProviders = useExternalProvidersStore((s) => s.providers);
+  const externalSelection = parseExternalModelId(checkpoint);
+  const selectedExternalProvider =
+    externalSelection != null
+      ? externalProviders.find((p) => p.id === externalSelection.providerId)
+      : undefined;
+  const isKimiExternal = selectedExternalProvider?.providerType === "kimi";
+  const disabled =
+    !modelLoaded || !(supportsTools || supportsBuiltinWebSearch);
 
   return (
     
           
- Cloud + Connections @@ -774,7 +829,7 @@ export function ChatProvidersSettings({ Provider

- Supported registry or Custom. + Supported registry or local OpenAI-compatible connection.

-
-
- -

- Stored locally. -

+ {showApiKeyField ? ( +
+
+ +

+ Stored locally. +

+
+
+ setApiKey(event.target.value)} + placeholder="Enter API key" + className="h-9 pr-9 text-sm" + /> + +
-
- setApiKey(event.target.value)} - placeholder="Enter API key" - className="h-9 pr-9 text-sm" - /> - -
-
+ ) : null} {isCustomProvider ? (
@@ -902,11 +976,35 @@ export function ChatProvidersSettings({ type="text" value={baseUrlDraft} onChange={(event) => setBaseUrlDraft(event.target.value)} - placeholder="https://my-vllm-server.com/v1" + placeholder={customProviderBaseUrlPlaceholder(providerType)} className="h-9 text-sm" />
) : null} + + {showReasoningToggle ? ( +
+ + +
+ ) : null}
@@ -952,7 +1050,7 @@ export function ChatProvidersSettings({ } title={ isCustomProvider - ? "Custom providers use manual model IDs" + ? "This connection uses manual model IDs" : isCuratedModelList ? "Full catalog is not fetched for this provider" : undefined @@ -986,7 +1084,7 @@ export function ChatProvidersSettings({ onChange={(event) => setManualModelIds(event.target.value) } - placeholder={"gpt-4o-mini\nQwen/Qwen3-14B"} + placeholder={customProviderModelIdsPlaceholder(providerType)} rows={5} className="min-h-[100px] resize-y font-mono text-sm" /> @@ -1197,9 +1295,9 @@ export function ChatProvidersSettings({
-

Cloud

+

Connections

- Manage cloud provider connections for chat through the Studio proxy. + Manage model provider connections for chat through the Studio proxy.

@@ -1237,7 +1335,8 @@ export function ChatProvidersSettings({ const detail = provider.baseUrl || registryEntry?.base_url || ""; const providerLabel = - registryEntry?.display_name ?? provider.providerType; + registryEntry?.display_name ?? + customProviderDisplayName(provider.providerType); const modelSummary = formatModelSummary(provider.models); return (
- Cloud + Connections - Manage external model providers for chat. + Manage external model connections for chat. @@ -517,6 +525,8 @@ interface ChatSettingsPanelProps { * per-param visibility in the sampling section. */ providerCapabilities?: ProviderCapabilities | null; + activeExternalProvider?: ExternalProviderConfig | null; + onExternalProviderChange?: (provider: ExternalProviderConfig) => void; /** * Backend provider type for the active external model (e.g. "kimi", * "anthropic", "openai"), or `null` for local models. Drives the @@ -533,6 +543,8 @@ export function ChatSettingsPanel({ onParamsChange, isExternalModel = false, providerCapabilities = null, + activeExternalProvider = null, + onExternalProviderChange, externalProviderType = null, onReloadModel, }: ChatSettingsPanelProps) { @@ -662,6 +674,26 @@ export function ChatSettingsPanel({ Boolean(currentCheckpoint) && modelRequiresTrustRemoteCode && !(params.trustRemoteCode ?? false); + const showPromptCachingControl = + activeExternalProvider != null && + supportsProviderPromptCaching(activeExternalProvider.providerType); + const promptCachingEnabled = + activeExternalProvider?.enablePromptCaching !== false; + const externalSelection = currentCheckpoint + ? parseExternalModelId(currentCheckpoint) + : null; + const showOpenAICodeExecSection = + activeExternalProvider != null && + providerSupportsBuiltinCodeExecution( + activeExternalProvider.providerType, + externalSelection?.modelId, + activeExternalProvider.baseUrl, + ) && + activeExternalProvider.providerType === "openai"; + const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId); + const openAiApiKeyForSection = activeExternalProvider + ? getExternalProviderApiKey(activeExternalProvider.id) || null + : null; function set(key: K) { return (v: InferenceParams[K]) => { @@ -1145,6 +1177,43 @@ export function ChatSettingsPanel({
+ {showPromptCachingControl && activeExternalProvider ? ( + +
+
+ + Prompt caching + + + Reuse compatible prompt prefixes for lower latency and cost. + +
+ { + onExternalProviderChange?.({ + ...activeExternalProvider, + enablePromptCaching: checked, + }); + }} + aria-label="Enable prompt caching" + /> +
+
+ ) : null} + + {showOpenAICodeExecSection && activeExternalProvider ? ( + + onExternalProviderChange?.(p)} + /> + + ) : null} + +
+ {sortedContainers.length === 0 ? ( + // Quiet placeholder with the same muted border as row cards + // so an empty section doesn't masquerade as an active control. + // The first container is minted by the chat-adapter on first + // send (lazy-create) and appears here after the next refresh. +
+ None yet - one will be created on first send. +
+ ) : ( +
    + {sortedContainers.map((c) => { + const running = isContainerRunning(c); + const isActive = running && c.id === displayActiveId; + const isPending = pendingIds.has(c.id); + const ttlMinutes = c.expiresAfterMinutes ?? DEFAULT_TTL_MINUTES; + const canActivate = + activeThreadId != null && !isActive && running; + const statusLabel = !running ? (c.status ?? "expired") : null; + return ( +
  • { + if (canActivate) void onPick(c.id); + }} + onKeyDown={(e) => { + if (!canActivate) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + void onPick(c.id); + } + }} + tabIndex={canActivate ? 0 : undefined} + role={canActivate ? "button" : undefined} + aria-pressed={isActive} + title={ + canActivate + ? "Use this container for the active thread" + : !running + ? `Container is ${statusLabel}` + : undefined + } + > + {/* min-w-0 + truncate keeps long OpenAI container ids + from spilling under the trash button on narrow + settings sheets. */} +
    +
    + + {c.name ?? "(unnamed)"} + + {isPending ? ( + + Creating + + ) : isActive ? ( + + Active + + ) : statusLabel ? ( + + {statusLabel} + + ) : null} +
    +
    + + {shortContainerId(c.id)} + + + · {ttlMinutes}m + +
    +
    + +
  • + ); + })} +
+ )} + + + {/* Create new — inline single-row edit that visually echoes a + container card. TTL is inherited from the section's top + "Idle timeout" control (no per-container override), which + keeps the form light and avoids a duplicated input. */} + {createOpen ? ( +
+ setCreateName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (createName.trim() && !creating && apiKey) { + void onCreate(); + } + } else if (e.key === "Escape") { + e.preventDefault(); + setCreateOpen(false); + setCreateName(""); + } + }} + className="h-7 min-w-0 flex-1 border-0 bg-transparent px-1.5 text-xs shadow-none focus-visible:ring-0" + /> + + +
+ ) : ( + + )} + + { + if (!nextOpen && deleting) return; + if (!nextOpen) setPendingDelete(null); + }} + > + + + + Delete{" "} + + {pendingDelete?.name ?? "container"} + + ? + + + Threads using this container will fall back to auto-create on + their next turn. This cannot be undone. + + + + Cancel + { + e.preventDefault(); + void confirmDelete(); + }} + > + {deleting ? "Deleting…" : "Delete"} + + + + + + ); +} diff --git a/studio/frontend/src/features/chat/external-providers.ts b/studio/frontend/src/features/chat/external-providers.ts index 867d0461cc..d455ff138e 100644 --- a/studio/frontend/src/features/chat/external-providers.ts +++ b/studio/frontend/src/features/chat/external-providers.ts @@ -14,10 +14,154 @@ export interface ExternalProviderConfig { models: string[]; /** Cached available model ids from the provider's /models response. */ availableModels?: string[]; + /** Whether to ask supported hosted providers to use prompt caching. */ + enablePromptCaching?: boolean; + /** User-pinned: the loaded vLLM model supports `enable_thinking`. */ + isReasoningModel?: boolean; + /** + * Default idle-timeout (in minutes) for newly created OpenAI shell + * containers. Pre-fills the "Create container" dialog and is the + * TTL the auto-create-per-thread path POSTs to /v1/containers with. + * OpenAI's hard default is 20. Only meaningful for OpenAI cloud. + */ + openaiContainerTtlMinutes?: number; createdAt: number; updatedAt: number; } +const PROMPT_CACHING_PROVIDER_TYPES = new Set(["openai", "anthropic"]); + +export function supportsProviderPromptCaching( + providerType: string | null | undefined, +): boolean { + return providerType != null && PROMPT_CACHING_PROVIDER_TYPES.has(providerType); +} + +// Provider types that expose the connection-level "reasoning model" +// toggle. vLLM's OpenAI-compat endpoint doesn't advertise this per model. +const REASONING_TOGGLE_PROVIDER_TYPES = new Set(["vllm"]); + +export function supportsProviderReasoningToggle( + providerType: string | null | undefined, +): boolean { + return ( + providerType != null && REASONING_TOGGLE_PROVIDER_TYPES.has(providerType) + ); +} + +export const CUSTOM_BACKEND_PROVIDER_TYPE = "openai"; +export const LEGACY_CUSTOM_PROVIDER_TYPE = "custom"; + +export const CUSTOM_PROVIDER_PRESETS = [ + { + providerType: "llama_cpp", + displayName: "llama.cpp", + baseUrlPlaceholder: "http://localhost:8080/v1", + modelIdsPlaceholder: "gpt-oss-20b\nqwen3-14b", + }, + { + providerType: "vllm", + displayName: "vLLM", + baseUrlPlaceholder: "https://my-vllm-server.com/v1", + modelIdsPlaceholder: "openai/gpt-oss-20b\nQwen/Qwen3-14B", + }, + { + providerType: "ollama", + displayName: "Ollama", + baseUrlPlaceholder: "http://localhost:11434/v1", + modelIdsPlaceholder: "gpt-oss:20b\nqwen3:14b", + }, +] as const; + +const CUSTOM_PROVIDER_LABELS: Record = { + [LEGACY_CUSTOM_PROVIDER_TYPE]: "Custom", + ...Object.fromEntries( + CUSTOM_PROVIDER_PRESETS.map((preset) => [ + preset.providerType, + preset.displayName, + ]), + ), +}; + +const CUSTOM_PROVIDER_BASE_URL_PLACEHOLDERS: Record = { + [LEGACY_CUSTOM_PROVIDER_TYPE]: "https://my-vllm-server.com/v1", + ...Object.fromEntries( + CUSTOM_PROVIDER_PRESETS.map((preset) => [ + preset.providerType, + preset.baseUrlPlaceholder, + ]), + ), +}; + +const CUSTOM_PROVIDER_MODEL_IDS_PLACEHOLDERS: Record = { + [LEGACY_CUSTOM_PROVIDER_TYPE]: "openai/gpt-oss-20b\nQwen/Qwen3-14B", + ...Object.fromEntries( + CUSTOM_PROVIDER_PRESETS.map((preset) => [ + preset.providerType, + preset.modelIdsPlaceholder, + ]), + ), +}; + +export function isCustomProviderType( + providerType: string | null | undefined, +): boolean { + if (!providerType) return false; + return providerType in CUSTOM_PROVIDER_LABELS; +} + +export function customProviderDisplayName( + providerType: string | null | undefined, +): string { + if (!providerType) return "Custom"; + return CUSTOM_PROVIDER_LABELS[providerType] ?? providerType; +} + +export function customProviderBaseUrlPlaceholder( + providerType: string | null | undefined, +): string { + if (!providerType) { + return CUSTOM_PROVIDER_BASE_URL_PLACEHOLDERS[LEGACY_CUSTOM_PROVIDER_TYPE]; + } + return ( + CUSTOM_PROVIDER_BASE_URL_PLACEHOLDERS[providerType] ?? + CUSTOM_PROVIDER_BASE_URL_PLACEHOLDERS[LEGACY_CUSTOM_PROVIDER_TYPE] + ); +} + +export function customProviderModelIdsPlaceholder( + providerType: string | null | undefined, +): string { + if (!providerType) { + return CUSTOM_PROVIDER_MODEL_IDS_PLACEHOLDERS[LEGACY_CUSTOM_PROVIDER_TYPE]; + } + return ( + CUSTOM_PROVIDER_MODEL_IDS_PLACEHOLDERS[providerType] ?? + CUSTOM_PROVIDER_MODEL_IDS_PLACEHOLDERS[LEGACY_CUSTOM_PROVIDER_TYPE] + ); +} + +export function toExternalBackendProviderType(providerType: string): string; +export function toExternalBackendProviderType( + providerType: null | undefined, +): undefined; +export function toExternalBackendProviderType( + providerType: string | null | undefined, +): string | undefined; +export function toExternalBackendProviderType( + providerType: string | null | undefined, +): string | undefined { + if (!providerType) return undefined; + // vLLM's /v1/responses applies the loaded model's chat template, which + // 400s on strict-alternation templates (e.g. Gemma 3). Pass the actual + // type through so the backend routes vLLM to /v1/chat/completions instead + // of the OpenAI Responses path used for gpt-5.x. + if (providerType === "vllm") return "vllm"; + return isCustomProviderType(providerType) + ? CUSTOM_BACKEND_PROVIDER_TYPE + : providerType; +} + const EXTERNAL_PROVIDERS_KEY = "unsloth_chat_external_providers"; const EXTERNAL_PROVIDER_KEYS_KEY = "unsloth_chat_external_provider_keys"; const EXTERNAL_MODEL_PREFIX = "external::"; @@ -71,9 +215,10 @@ function mapLegacyPresetToProviderType(presetId: string): string { } function normalizeProvider(raw: ExternalProviderConfig): ExternalProviderConfig { + const providerType = raw.providerType.trim(); return { ...raw, - providerType: raw.providerType.trim(), + providerType, name: raw.name.trim(), baseUrl: raw.baseUrl.trim(), models: raw.models @@ -82,6 +227,18 @@ function normalizeProvider(raw: ExternalProviderConfig): ExternalProviderConfig availableModels: (raw.availableModels ?? []) .map((model) => model.trim()) .filter((model) => model.length > 0), + enablePromptCaching: supportsProviderPromptCaching(providerType) + ? raw.enablePromptCaching !== false + : undefined, + isReasoningModel: supportsProviderReasoningToggle(providerType) + ? raw.isReasoningModel === true + : undefined, + openaiContainerTtlMinutes: + providerType === "openai" && + typeof raw.openaiContainerTtlMinutes === "number" && + raw.openaiContainerTtlMinutes >= 1 + ? Math.min(raw.openaiContainerTtlMinutes, 20) + : undefined, }; } diff --git a/studio/frontend/src/features/chat/lib/friendly-names.ts b/studio/frontend/src/features/chat/lib/friendly-names.ts new file mode 100644 index 0000000000..503008f761 --- /dev/null +++ b/studio/frontend/src/features/chat/lib/friendly-names.ts @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +/** + * Friendly default names for auto-created OpenAI shell containers. + * Used by the chat-adapter when the lazy-create path fires (Code pill + * on, no thread container yet, user has set a non-default TTL). The + * goal is a human-memorable label like "otter" or "harbor" instead of + * "chat-abc12345" — the user can still rename via the Studio-side + * alias map. + * + * The list is curated to: + * - Be unambiguous, non-offensive nouns from natural categories + * (animals, plants, geography, materials, weather). + * - Avoid technical / political / brand words that might read as + * odd in a chat UI. + * - Stay reasonably small so the bundle cost is negligible (~200 + * entries × ~7 bytes ≈ 1.5 KB). + * + * Collisions are tolerated — the container's real unique key is its + * ``cntr_*`` id, not its name. A short random hex suffix is appended + * to make accidental same-name collisions visually distinct in the + * picker list. + */ + +const WORDS = [ + // animals + "otter", + "falcon", + "heron", + "lynx", + "marten", + "stoat", + "raven", + "magpie", + "salmon", + "trout", + "perch", + "tortoise", + "gecko", + "iguana", + "axolotl", + "narwhal", + "manatee", + "dolphin", + "porpoise", + "octopus", + "cuttlefish", + "nautilus", + "starfish", + "urchin", + "anemone", + "coral", + "puffin", + "kestrel", + "osprey", + "buzzard", + "kingfisher", + "robin", + "wren", + "finch", + "sparrow", + "thrush", + "siskin", + "warbler", + "tanager", + "oriole", + "hare", + "badger", + "weasel", + "ferret", + "polecat", + "civet", + "tapir", + "okapi", + "ibex", + "chamois", + // plants & trees + "alder", + "aspen", + "birch", + "cedar", + "cypress", + "elder", + "elm", + "fir", + "ginkgo", + "hawthorn", + "hazel", + "hemlock", + "holly", + "juniper", + "larch", + "linden", + "maple", + "oak", + "olive", + "pine", + "rowan", + "spruce", + "sycamore", + "willow", + "yew", + "thistle", + "fern", + "moss", + "ivy", + "clover", + "heather", + "lavender", + "rosemary", + "sage", + "thyme", + "myrtle", + "laurel", + "magnolia", + // geography / landscape + "harbor", + "atoll", + "lagoon", + "estuary", + "fjord", + "delta", + "isthmus", + "mesa", + "plateau", + "valley", + "ridge", + "summit", + "glade", + "meadow", + "moor", + "heath", + "tundra", + "savanna", + "prairie", + "steppe", + "bayou", + "marsh", + "fen", + "grotto", + "cavern", + "canyon", + "ravine", + "gorge", + "knoll", + "dell", + "vale", + "coast", + // materials / minerals / colors + "amber", + "agate", + "onyx", + "opal", + "jade", + "quartz", + "obsidian", + "basalt", + "granite", + "marble", + "slate", + "flint", + "lapis", + "topaz", + "garnet", + "pearl", + "coral", + "ivory", + "ebony", + "copper", + "cobalt", + "indigo", + "saffron", + "vermilion", + "ochre", + "umber", + "sienna", + "russet", + // weather / sky / time + "aurora", + "comet", + "ember", + "frost", + "gale", + "harvest", + "monsoon", + "nebula", + "solstice", + "twilight", + "zephyr", + "drizzle", + "tempest", + "halcyon", + "equinox", + "rainbow", + "horizon", + "meridian", + "zenith", + "comet", + // misc tactile / cozy nouns + "lantern", + "kettle", + "compass", + "anchor", + "beacon", + "harbor", + "voyage", + "trellis", + "cottage", + "thicket", + "orchard", + "bramble", + "haystack", + "snowfall", + "campfire", +]; + +/** RFC 4122-ish 4-character lowercase hex suffix using crypto.randomUUID. */ +function randomHexSuffix(): string { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return crypto.randomUUID().replace(/-/g, "").slice(0, 4); + } + // Older browser fallback. Math.random is fine here — this is a + // display suffix, not a security token. + return Math.floor(Math.random() * 0xffff) + .toString(16) + .padStart(4, "0"); +} + +/** + * Returns a single English-word name with a short random hex suffix. + * + * Example output: "kestrel-3f9c", "harbor-a012". + * + * The suffix keeps containers visually distinguishable in the picker + * when the same word recurs across creations. + */ +export function pickFriendlyContainerName(): string { + const word = WORDS[Math.floor(Math.random() * WORDS.length)] ?? "container"; + return `${word}-${randomHexSuffix()}`; +} diff --git a/studio/frontend/src/features/chat/provider-capabilities.ts b/studio/frontend/src/features/chat/provider-capabilities.ts index 0688d2d673..3c9bff40b9 100644 --- a/studio/frontend/src/features/chat/provider-capabilities.ts +++ b/studio/frontend/src/features/chat/provider-capabilities.ts @@ -83,6 +83,126 @@ export function clampReasoningEffortToLevels( */ export const EXTERNAL_MAX_OUTPUT_TOKENS = 32768; +/** + * Whether the external provider offers a built-in web-search tool that the + * model invokes server-side. When `true`, the chat composer's Search button + * is available for that provider and the chat-adapter forwards + * `enable_tools: true, enabled_tools: ["web_search"]` on the request — the + * backend routes the call through the provider's tool schema: + * - OpenAI: `tools: [{type: "web_search"}]` on /v1/responses + * - Anthropic: `tools: [{type: "web_search_20250305", name: "web_search", + * max_uses: 5}]` on /v1/messages + * - OpenRouter: `plugins: [{id: "web"}]` on /v1/chat/completions (the + * router's universal web-search shape; works for every + * underlying model including the `openrouter/free` router). + * - Kimi: `tools: [{type: "builtin_function", function: {name: + * "$web_search"}}]` with `thinking: {type: + * "disabled"}`. Requires a client round-trip: + * the first call returns the search args; the backend + * echoes them back as a role=tool message; the second + * call streams the answer. Handled in + * _stream_kimi_web_search on the backend. + * + * Mistral is intentionally excluded: their `web_search` connector lives on + * the Agents API (`/v1/agents` + `/v1/conversations`), not chat completions, + * and returns `"WebSearchTool connector is not supported"` if injected into + * /v1/chat/completions. Wiring it would require a dedicated Agents streaming + * path. Gemini's grounded-search can be added with the same pattern when + * matching backend translation lands. + */ +export function providerSupportsBuiltinWebSearch( + providerType: string | null | undefined, +): boolean { + return ( + providerType === "openai" || + providerType === "anthropic" || + providerType === "openrouter" || + providerType === "kimi" + ); +} + +/** + * Whether the selected external provider/model exposes a server-side + * code-execution tool. Two providers ship one today: + * + * - **Anthropic** (`code_execution_20250825`): Python + bash + + * str_replace-based file edits inside a 5 GB sandboxed container + * per request. Documented at + * https://platform.claude.com/docs/en/agents-and-tools/tool-use/code-execution-tool + * + * - **OpenAI cloud** (`shell` on /v1/responses): bash inside a + * reusable container; we auto-create one on the first turn of a + * chat thread and reference it on subsequent turns via the + * thread's stored `openaiCodeExecContainerId`. Documented at + * https://developers.openai.com/api/docs/guides/tools-shell + * + * Returns false for every other provider. The backend additionally + * gates the OpenAI shell tool on `is_openai_cloud` so custom + * OpenAI-compat servers (ollama / llama.cpp / vLLM) that also report + * `provider_type="openai"` never receive the tool — but in practice + * none of those catalogs surface the `gpt-5.5` ids anyway, so the + * frontend prefix match is enough. + * + * v1 wires the tools themselves; file uploads (Anthropic + * `container_upload` / OpenAI `input_file`) are a deliberate follow-up. + */ +const ANTHROPIC_CODE_EXECUTION_MODEL_PREFIXES = [ + "claude-opus-4-7", + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-opus-4-5", + "claude-sonnet-4-5", + "claude-haiku-4-5", + // Deprecated upstream but the registry still exposes the ids, so the + // pill should remain functional for users on those snapshots. + "claude-opus-4-1", + "claude-opus-4", + "claude-sonnet-4", +] as const; + +// OpenAI cloud shell-tool gating. Docs only explicitly demonstrate +// gpt-5.5; gpt-5.5-pro is included because the family share the same +// /v1/responses contract. `gpt-5.5-pro` is checked first so the prefix +// match doesn't collide with a hypothetical `gpt-5.5-turbo` etc. +const OPENAI_CODE_EXECUTION_MODEL_PREFIXES = [ + "gpt-5.5-pro", + "gpt-5.5", +] as const; + +/** + * Strict check that a provider configuration points at OpenAI's + * managed cloud (api.openai.com), as opposed to a custom OpenAI-compat + * backend (ollama / llama.cpp / vLLM / generic "custom" preset). The + * shell tool ONLY exists on OpenAI cloud; sending it to anything else + * 400s the request. Mirror of the backend's + * `is_openai_cloud = "api.openai.com" in self.base_url` guard. + */ +function isOpenAICloudBaseUrl(baseUrl: string | null | undefined): boolean { + if (!baseUrl) return true; // No override → uses the default openai.com base. + return baseUrl.trim().toLowerCase().includes("api.openai.com"); +} + +export function providerSupportsBuiltinCodeExecution( + providerType: string | null | undefined, + modelId: string | null | undefined, + baseUrl?: string | null, +): boolean { + const normalized = modelId?.trim().toLowerCase() ?? ""; + if (!normalized) return false; + if (providerType === "anthropic") { + return ANTHROPIC_CODE_EXECUTION_MODEL_PREFIXES.some((prefix) => + normalized.startsWith(prefix), + ); + } + if (providerType === "openai") { + if (!isOpenAICloudBaseUrl(baseUrl)) return false; + return OPENAI_CODE_EXECUTION_MODEL_PREFIXES.some((prefix) => + normalized.startsWith(prefix), + ); + } + return false; +} + /** * Per-provider minimum on the outbound max_tokens. Kimi's docs require * `max_tokens >= 16000` whenever a thinking model is in use so the @@ -183,10 +303,13 @@ const PROVIDER_CAPABILITIES: Record = { // OpenRouter silently drops params the target model does not support, so we // surface every knob and let the gateway handle the per-model fan-out. openrouter: ALL_SUPPORTED, - // Custom providers are assumed OpenAI-compatible by the backend; users who - // point at vLLM/Ollama backends often want top_k / min_p / repetition, - // so be permissive. + // Local OpenAI-compatible connections are proxied through the OpenAI backend + // path, but vLLM/Ollama/llama.cpp users often want top_k / min_p / + // repetition controls, so be permissive. custom: ALL_SUPPORTED, + vllm: ALL_SUPPORTED, + ollama: ALL_SUPPORTED, + llama_cpp: ALL_SUPPORTED, }; const DEFAULT_EXTERNAL_CAPABILITIES = OPENAI_COMPAT_BASE; @@ -239,7 +362,7 @@ const NO_REASONING_CAPS: ReasoningCaps = { const ANTHROPIC_REASONING_MODELS = [ { prefixes: ["claude-opus-4-7"], - levels: ["none", "low", "medium", "high", "xhigh"], + levels: ["none", "low", "medium", "high", "xhigh", "max"], }, { prefixes: ["claude-opus-4-6", "claude-sonnet-4-6"], @@ -382,6 +505,25 @@ function resolveMistralReasoningCapabilities(modelId: string): ExternalReasoning return withEnableThinkingStyle(); } +export interface ExternalReasoningResolveOptions { + /** vLLM connection flagged as a reasoning model in provider config. */ + isReasoningProvider?: boolean; +} + +// vLLM has no per-model reasoning signal on OpenAI-compat — pin via user toggle. +function resolveConnectionLevelReasoning( + normalizedProvider: string, + options: ExternalReasoningResolveOptions | undefined, +): ExternalReasoningCapabilities | null { + if (normalizedProvider === "vllm" && options?.isReasoningProvider) { + return withEnableThinkingStyle({ + supportsReasoning: true, + supportsReasoningOff: true, + }); + } + return null; +} + /** * resolve external-model thinking capabilities. * provider-specific matching lives in the OpenAI/Anthropic resolvers. @@ -390,9 +532,17 @@ function resolveMistralReasoningCapabilities(modelId: string): ExternalReasoning export function getExternalReasoningCapabilities( providerType: string | null | undefined, modelId: string | null | undefined, + options?: ExternalReasoningResolveOptions, ): ExternalReasoningCapabilities { const normalizedModel = modelId?.trim().toLowerCase() ?? ""; const normalizedProvider = providerType?.trim().toLowerCase() ?? ""; + const connectionLevel = resolveConnectionLevelReasoning( + normalizedProvider, + options, + ); + if (connectionLevel) { + return connectionLevel; + } if (!normalizedModel) { return withEnableThinkingStyle(); } diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx index 1133e759ac..b019fd2d4d 100644 --- a/studio/frontend/src/features/chat/runtime-provider.tsx +++ b/studio/frontend/src/features/chat/runtime-provider.tsx @@ -396,7 +396,7 @@ function toThreadMessage(m: MessageRecord): ThreadMessage { }; } -async function ensureThreadRecord({ +export async function ensureThreadRecord({ threadId, modelType, pairId, diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index ceef9f501b..cd31d37cd7 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -24,7 +24,10 @@ import { type ReasoningEffort, useChatRuntimeStore, } from "./stores/chat-runtime-store"; -import { getExternalReasoningCapabilities } from "./provider-capabilities"; +import { + getExternalReasoningCapabilities, + providerSupportsBuiltinCodeExecution, +} from "./provider-capabilities"; import { type CompositionEvent, type KeyboardEvent, @@ -304,6 +307,9 @@ export function SharedComposer({ const preserveThinking = useChatRuntimeStore((s) => s.preserveThinking); const setPreserveThinking = useChatRuntimeStore((s) => s.setPreserveThinking); const supportsTools = useChatRuntimeStore((s) => s.supportsTools); + const supportsBuiltinWebSearch = useChatRuntimeStore( + (s) => s.supportsBuiltinWebSearch, + ); const toolsEnabled = useChatRuntimeStore((s) => s.toolsEnabled); const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled); const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled); @@ -327,6 +333,10 @@ export function SharedComposer({ ? getExternalReasoningCapabilities( selectedExternalProvider?.providerType, effectiveExternalModelId, + { + isReasoningProvider: + selectedExternalProvider?.isReasoningModel === true, + }, ) : null; const isExternalOpenAIReasoning = @@ -345,13 +355,39 @@ export function SharedComposer({ const reasoningLockedOn = effectiveSupportsReasoning && (effectiveReasoningAlwaysOn || !effectiveSupportsReasoningOff); + // Kimi's $web_search builtin mandates thinking=disabled per the docs at + // https://platform.kimi.ai/docs/guide/use-web-search. Both pills stay + // clickable for Kimi, but turning one on flips the other off — the + // click handlers below enforce this mutual exclusion so the visible + // state always matches what the backend actually sends. + const isKimiExternal = selectedExternalProvider?.providerType === "kimi"; const effectiveReasoningEnabled = reasoningLockedOn ? true : reasoningEnabled; const effectiveReasoningVisualEnabled = effectiveReasoningEnabled && reasoningEffort !== "none"; const reasoningDisabled = !modelLoaded || !effectiveSupportsReasoning; const showReasoningControl = effectiveSupportsReasoning || effectiveReasoningAlwaysOn; - const toolsDisabled = !modelLoaded || !supportsTools; + // Two-pill gating: Search pill lights up when the runtime has either + // a local tool runtime (supportsTools, gives us our Code/python + local + // web_search) OR a server-side web_search the provider runs for us + // (supportsBuiltinWebSearch, currently OpenAI / Anthropic / OpenRouter + // / Kimi). Code pill lights up on the local runtime OR when Anthropic + // is selected with a model that accepts the server-side + // code_execution_20250825 tool — see + // providerSupportsBuiltinCodeExecution. Anthropic is the only external + // provider that ships a code-execution tool today. + const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution( + selectedExternalProvider?.providerType, + effectiveExternalModelId, + selectedExternalProvider?.baseUrl, + ); + const searchDisabled = + !modelLoaded || !(supportsTools || supportsBuiltinWebSearch); + const codeDisabled = + !modelLoaded || !(supportsTools || supportsBuiltinCodeExecution); + // Backwards-compatible alias for any other call site that may still + // reference `toolsDisabled` (rare; both pills used it before). + const toolsDisabled = codeDisabled; const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio); const clearPendingAudioStore = useChatRuntimeStore((s) => s.clearPendingAudio); @@ -654,6 +690,9 @@ export function SharedComposer({ placeholder="Send to both models..." className="composer-input" rows={1} + // dir="auto" auto-detects RTL (Arabic / Hebrew / Persian / Urdu) + // from the first strong character; no effect on LTR scripts. + dir="auto" />
@@ -766,6 +805,11 @@ export function SharedComposer({ setReasoningEffort(level); setReasoningEnabled(true); applyQwenThinkingParams(true); + // Mutual exclusion: turning thinking on for a + // Kimi model forces the web_search builtin off. + if (isKimiExternal && toolsEnabled) { + setToolsEnabled(false); + } }} > {formatReasoningEffortLabel(level, externalSelection?.modelId)} @@ -789,6 +833,12 @@ export function SharedComposer({ const next = !reasoningEnabled; setReasoningEnabled(next); applyQwenThinkingParams(next); + // Mutual exclusion: Kimi's $web_search builtin + // requires thinking off, so turning thinking on flips + // the Search pill off (and vice versa). + if (isKimiExternal && next && toolsEnabled) { + setToolsEnabled(false); + } }} className={cn( "flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors", @@ -845,10 +895,22 @@ export function SharedComposer({ )}