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 0f6f89d354..6b008d4bb1 100644 --- a/.github/workflows/consolidated-tests-ci.yml +++ b/.github/workflows/consolidated-tests-ci.yml @@ -121,6 +121,8 @@ jobs: UNSLOTH_IS_PRESENT: '1' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -204,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' @@ -269,6 +272,50 @@ jobs: tests/utils/test_trunc_normal_patch.py python -m pytest --collect-only -q "$RUNNER_TEMP/unsloth-zoo/tests/" + - name: import_fixes drift detectors (18 tests, HARD GATE) + # One drift detector per fix_* / patch_* function in + # unsloth/import_fixes.py. The detectors assert the *healthy* + # upstream shape that the fix expects ABSENT the regression; + # ANY DRIFT DETECTED -> pytest.fail (NEVER skip) so the + # matrix cell goes red and the maintainer triages on the + # next PR, not in a downstream user's crash report. + # + # Pathologies covered by the suite (each maps to one fix + # function with the line range cited in the test docstring): + # * protobuf MessageFactory GetPrototype / GetMessageClass + # * datasets 4.4.x recursion range + # * TRL tuple-vs-bool _*_available caching + # * transformers PreTrainedModel.enable_input_require_grads + # source pattern flip + # * transformers torchcodec / causal_conv1d availability + # flags + # * transformers + accelerate is_wandb_available + # * peft.utils.transformers_weight_conversion importability + # + build_peft_weight_mapping signature + # * triton 3.6+ CompiledKernel num_ctas / cluster_dims + # * torch / torchvision pinned compatibility table + # * vllm guided_decoding_params / structured_outputs + + # aimv2 ovis config version + # * huggingface_hub is_offline_mode / HF_HUB_OFFLINE + # * torch.nn.init.trunc_normal_ presence (patch site for + # patch_trunc_normal_precision_issue) + # * xformers post-num_splits-key fix version + # HARD GATE: a red cell here is a real upstream regression + # without a corresponding zoo / unsloth-side workaround. + 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 @@ -840,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 @@ -906,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", } @@ -921,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: @@ -985,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]) @@ -2013,6 +2104,8 @@ jobs: UNSLOTH_IS_PRESENT: '1' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/lint-ci.yml b/.github/workflows/lint-ci.yml index 49b7f7d9b2..00e6e357e2 100644 --- a/.github/workflows/lint-ci.yml +++ b/.github/workflows/lint-ci.yml @@ -44,6 +44,8 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/mlx-ci.yml b/.github/workflows/mlx-ci.yml index 4cabfd01f5..75940832a0 100644 --- a/.github/workflows/mlx-ci.yml +++ b/.github/workflows/mlx-ci.yml @@ -100,6 +100,8 @@ jobs: egress-policy: audit - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -300,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 587f27ea6d..673b2f3cc5 100644 --- a/.github/workflows/notebooks-ci.yml +++ b/.github/workflows/notebooks-ci.yml @@ -88,6 +88,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: unsloth + persist-credentials: false - name: Checkout unslothai/notebooks @ ${{ env.NOTEBOOKS_REF }} uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -96,6 +97,7 @@ jobs: ref: ${{ env.NOTEBOOKS_REF }} path: notebooks fetch-depth: 0 # drift check needs git status / diff + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -196,12 +198,15 @@ jobs: exit 1 fi - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: { path: unsloth } + with: + persist-credentials: false + path: unsloth - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: unslothai/notebooks ref: ${{ env.NOTEBOOKS_REF }} path: notebooks + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: { python-version: '3.12', cache: 'pip' } - name: Install @@ -239,12 +244,15 @@ jobs: exit 1 fi - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: { path: unsloth } + with: + persist-credentials: false + path: unsloth - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: unslothai/notebooks ref: ${{ env.NOTEBOOKS_REF }} path: notebooks + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: { python-version: '3.12', cache: 'pip' } @@ -342,12 +350,15 @@ jobs: exit 1 fi - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: { path: unsloth } + with: + persist-credentials: false + path: unsloth - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: unslothai/notebooks ref: ${{ env.NOTEBOOKS_REF }} path: notebooks + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: { python-version: '3.12' } diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index fbfeece614..810bb644ba 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -36,6 +36,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Validate release versions id: prepare @@ -343,6 +345,8 @@ jobs: egress-policy: audit - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false # ── Linux dependencies ── - name: Install Linux dependencies diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index f739e852fd..a1e7b2efa6 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -127,6 +127,7 @@ jobs: # Full history so TruffleHog can diff base..head; without # this it sees only the latest commit and reports nothing. fetch-depth: 0 + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -136,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 @@ -722,6 +721,8 @@ jobs: files.pythonhosted.org:443 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -893,6 +894,8 @@ jobs: registry.npmjs.org:443 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -963,6 +966,8 @@ jobs: files.pythonhosted.org:443 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -998,6 +1003,8 @@ jobs: files.pythonhosted.org:443 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -1052,12 +1059,11 @@ jobs: # Need the base commit accessible for `git show # :studio/frontend/package-lock.json` below. fetch-depth: 0 + persist-credentials: false - 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 668111d1dc..53514e2ce1 100644 --- a/.github/workflows/studio-api-smoke.yml +++ b/.github/workflows/studio-api-smoke.yml @@ -51,6 +51,8 @@ jobs: HF_HOME: ${{ github.workspace }}/hf-cache steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Linux deps run: | @@ -61,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: @@ -85,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-backend-ci.yml b/.github/workflows/studio-backend-ci.yml index 59cd3a5685..63eb70f7f1 100644 --- a/.github/workflows/studio-backend-ci.yml +++ b/.github/workflows/studio-backend-ci.yml @@ -53,6 +53,8 @@ jobs: python: ['3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -106,6 +108,8 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/studio-frontend-ci.yml b/.github/workflows/studio-frontend-ci.yml index 5c5405c604..3632125ca2 100644 --- a/.github/workflows/studio-frontend-ci.yml +++ b/.github/workflows/studio-frontend-ci.yml @@ -36,6 +36,8 @@ jobs: working-directory: studio/frontend steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false # FIXME: drop this step once @assistant-ui/* and assistant-stream # leave 0.x -- on 1.x, caret ranges are conventional. Until then, @@ -55,8 +57,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`, diff --git a/.github/workflows/studio-inference-smoke.yml b/.github/workflows/studio-inference-smoke.yml index 922d883cc9..775363e73c 100644 --- a/.github/workflows/studio-inference-smoke.yml +++ b/.github/workflows/studio-inference-smoke.yml @@ -67,6 +67,8 @@ jobs: HF_HOME: ${{ github.workspace }}/hf-cache steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Linux deps for llama.cpp prebuilt run: | @@ -77,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: @@ -99,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' @@ -315,6 +314,8 @@ jobs: STUDIO_PORT: '18889' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Linux deps for llama.cpp prebuilt run: | @@ -325,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: @@ -347,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' @@ -632,6 +630,8 @@ jobs: HF_HOME: ${{ github.workspace }}/hf-cache steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Linux deps for llama.cpp prebuilt run: | @@ -642,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: @@ -664,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 6a98776fa3..b4e274155e 100644 --- a/.github/workflows/studio-mac-api-smoke.yml +++ b/.github/workflows/studio-mac-api-smoke.yml @@ -44,12 +44,12 @@ jobs: HF_HOME: ${{ github.workspace }}/hf-cache steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - 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: @@ -70,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 82438f0c27..2d6864e0cb 100644 --- a/.github/workflows/studio-mac-inference-smoke.yml +++ b/.github/workflows/studio-mac-inference-smoke.yml @@ -4,8 +4,8 @@ # Three end-to-end smoke jobs that boot a freshly-installed Studio and # exercise the surfaces real users hit through the OpenAI / Anthropic # SDKs and curl. Each job picks the smallest model that exercises the -# behaviour under test, primes HF_HOME via actions/cache, and shares -# the install.sh --local --no-torch bootstrap. +# behaviour under test, primes a model cache via actions/cache, and +# shares the install.sh --local --no-torch bootstrap. # # 1. OpenAI, Anthropic API tests # gemma-3-270m-it UD-Q4_K_XL (~254 MiB). @@ -40,7 +40,7 @@ on: - '.github/workflows/studio-mac-inference-smoke.yml' push: branches: [main, pip] - # Manual trigger for pre-warming HF_HOME caches on main, or re-running + # Manual trigger for pre-warming model caches on main, or re-running # against an arbitrary branch without pushing a no-op commit. workflow_dispatch: @@ -67,12 +67,12 @@ jobs: HF_HOME: ${{ github.workspace }}/hf-cache steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - 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: @@ -93,13 +93,14 @@ 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. - name: Save HF_HOME for ${{ env.GGUF_REPO }} - if: always() && steps.prime-hf.outcome == 'success' + if: always() && steps.prime-hf.outcome != 'skipped' && hashFiles('hf-cache/**/*.gguf') != '' uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: hf-cache @@ -315,12 +316,12 @@ jobs: STUDIO_PORT: '18898' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - 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: @@ -341,13 +342,13 @@ 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 - if: always() && steps.download-gguf.outcome == 'success' + if: always() && steps.download-gguf.outcome != 'skipped' && hashFiles('gguf-cache/**/*.gguf') != '' uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: gguf-cache @@ -677,57 +678,72 @@ jobs: GGUF_FILE: gemma-4-E2B-it-UD-Q4_K_XL.gguf MMPROJ_FILE: mmproj-F16.gguf STUDIO_PORT: '18899' - HF_HOME: ${{ github.workspace }}/hf-cache steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - cache: 'npm' - cache-dependency-path: studio/frontend/package-lock.json - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' cache: 'pip' - - name: Restore HF_HOME for ${{ env.GGUF_REPO }} (model + mmproj) - id: cache-hf + # Cache flat .gguf + mmproj (Job 2's pattern). HF_HOME inflates + # ~3.6x via xet/blobs/snapshots, which made macOS saves never land. + # mmproj is auto-detected as a sibling via detect_mmproj_file + # (studio/backend/utils/models/model_config.py). + - name: Restore GGUF + mmproj files + id: cache-gguf uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 continue-on-error: true with: - path: hf-cache - key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v1 + path: gguf-cache + key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-${{ env.MMPROJ_FILE }}-v2 - - name: Prime HF_HOME with the GGUF + mmproj - id: prime-hf - if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' + - 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.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 - mkdir -p hf-cache - HF_HUB_ENABLE_HF_TRANSFER=1 \ - hf download "$GGUF_REPO" "$GGUF_FILE" & + python -m pip install --upgrade huggingface_hub + mkdir -p 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" & + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$MMPROJ_FILE" gguf-cache & MMPROJ_PID=$! wait "$MODEL_PID" wait "$MMPROJ_PID" # Fail loud on a partial download instead of in the next step. - find hf-cache -name "$GGUF_FILE" -o -name "$MMPROJ_FILE" \ - | xargs -I{} ls -lhL {} + ls -lh "gguf-cache/$GGUF_FILE" "gguf-cache/$MMPROJ_FILE" - - name: Save HF_HOME for ${{ env.GGUF_REPO }} (model + mmproj) - if: always() && steps.prime-hf.outcome == 'success' + # Save partial caches on cancel. hashFiles guard avoids a hard + # 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') != '' && hashFiles(format('gguf-cache/{0}', env.MMPROJ_FILE)) != '' uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: - path: hf-cache - key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v1 + path: gguf-cache + key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-${{ env.MMPROJ_FILE }}-v2 - name: Install Studio (--local, --no-torch) env: @@ -782,12 +798,17 @@ jobs: -H 'content-type: application/json' \ -d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token) echo "API_KEY=$TOKEN" >> "$GITHUB_ENV" - # Load the GGUF (mmproj is auto-detected via the HF repo - # lookup, the cached file is pulled out of HF_HOME). + # Load via local file path; mmproj sibling auto-detected by + # detect_mmproj_file (model_config.py). gguf_variant omitted + # -- it routes through _find_local_gguf_by_variant which + # expects a directory, not a file path. + GGUF_PATH="$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" + MMPROJ_PATH="$GITHUB_WORKSPACE/gguf-cache/${MMPROJ_FILE}" + ls -lh "$GGUF_PATH" "$MMPROJ_PATH" curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ --max-time 900 \ - -d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}" \ + -d "{\"model_path\":\"$GGUF_PATH\",\"is_lora\":false,\"max_seq_length\":2048}" \ | jq '{status, display_name, is_vision}' - name: JSON schema decoding + image input diff --git a/.github/workflows/studio-mac-ui-smoke.yml b/.github/workflows/studio-mac-ui-smoke.yml index df3654277f..510c3543d2 100644 --- a/.github/workflows/studio-mac-ui-smoke.yml +++ b/.github/workflows/studio-mac-ui-smoke.yml @@ -44,12 +44,12 @@ jobs: HF_HOME: ${{ github.workspace }}/hf-cache steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - 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: @@ -70,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 2733fef1d1..07d26b9ab3 100644 --- a/.github/workflows/studio-mac-update-smoke.yml +++ b/.github/workflows/studio-mac-update-smoke.yml @@ -46,12 +46,12 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - 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 5254ef39c5..1156c264ae 100644 --- a/.github/workflows/studio-tauri-smoke.yml +++ b/.github/workflows/studio-tauri-smoke.yml @@ -40,6 +40,8 @@ jobs: timeout-minutes: 25 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Linux native deps for Tauri / WebKit2GTK run: | @@ -51,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 82496c3665..79476a62ea 100644 --- a/.github/workflows/studio-ui-smoke.yml +++ b/.github/workflows/studio-ui-smoke.yml @@ -52,6 +52,8 @@ jobs: HF_HOME: ${{ github.workspace }}/hf-cache steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Linux deps run: | @@ -62,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: @@ -84,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' diff --git a/.github/workflows/studio-update-smoke.yml b/.github/workflows/studio-update-smoke.yml index 574b447a94..1c353e933a 100644 --- a/.github/workflows/studio-update-smoke.yml +++ b/.github/workflows/studio-update-smoke.yml @@ -40,6 +40,8 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Linux deps for llama.cpp prebuilt run: | @@ -50,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 fd80377352..1d12ea6f90 100644 --- a/.github/workflows/studio-windows-api-smoke.yml +++ b/.github/workflows/studio-windows-api-smoke.yml @@ -52,12 +52,12 @@ jobs: PYTHONUTF8: '1' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - 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: @@ -77,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 13bd8e58e2..01bf4127a7 100644 --- a/.github/workflows/studio-windows-inference-smoke.yml +++ b/.github/workflows/studio-windows-inference-smoke.yml @@ -62,12 +62,12 @@ jobs: PYTHONUTF8: '1' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - 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: @@ -99,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 @@ -343,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() @@ -388,12 +391,12 @@ jobs: PYTHONUTF8: '1' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - 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: @@ -416,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' @@ -758,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() @@ -796,12 +802,12 @@ jobs: PYTHONUTF8: '1' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - 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: @@ -827,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' @@ -1144,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 6ee262163b..e5ab9f8ab7 100644 --- a/.github/workflows/studio-windows-ui-smoke.yml +++ b/.github/workflows/studio-windows-ui-smoke.yml @@ -57,12 +57,19 @@ jobs: PYTHONUTF8: '1' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - 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: @@ -86,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 c16edc5aff..157874d404 100644 --- a/.github/workflows/studio-windows-update-smoke.yml +++ b/.github/workflows/studio-windows-update-smoke.yml @@ -58,12 +58,12 @@ jobs: PYTHONUTF8: '1' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - 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 b14a759916..2fbdd15747 100644 --- a/.github/workflows/version-compat-ci.yml +++ b/.github/workflows/version-compat-ci.yml @@ -58,6 +58,8 @@ jobs: timeout-minutes: 12 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' @@ -83,6 +85,8 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' @@ -107,6 +111,8 @@ jobs: timeout-minutes: 8 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' @@ -129,6 +135,8 @@ jobs: timeout-minutes: 8 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' @@ -151,6 +159,8 @@ jobs: timeout-minutes: 8 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' @@ -173,6 +183,8 @@ jobs: timeout-minutes: 12 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' @@ -200,7 +212,9 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: { path: unsloth } + with: + persist-credentials: false + path: unsloth - name: Clone unsloth-zoo @ main run: | # github.com occasionally 500s on the git fetch; retry so a @@ -279,6 +293,8 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' diff --git a/.github/workflows/wheel-smoke.yml b/.github/workflows/wheel-smoke.yml index a2d5d650e2..3de3c33ca2 100644 --- a/.github/workflows/wheel-smoke.yml +++ b/.github/workflows/wheel-smoke.yml @@ -42,12 +42,12 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - 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/scripts/verify_comment_only_diff.py b/scripts/verify_comment_only_diff.py new file mode 100644 index 0000000000..90eafb7f8f --- /dev/null +++ b/scripts/verify_comment_only_diff.py @@ -0,0 +1,247 @@ +# Unsloth - 2x faster, 60% less VRAM LLM training and finetuning +# Copyright 2023-present Daniel Han-Chen, Michael Han-Chen & the Unsloth team. All rights reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. + +"""Deterministic comment / docstring-only verifier. + +Compares a list of changed files between two git refs and reports whether +each diff is strictly comments / docstrings (Python) or comments +(YAML / GitHub Actions). Useful for gating a "comment trim" / +"docstring refactor" PR against accidental code drift. + +Per .py file: parse both revs into AST, strip module / class / function +docstrings, then compare ast.unparse output. Pure Python comments are +discarded by the parser by construction, so any post-strip diff is real +code. Per .yml file: yaml.safe_load both sides and compare the parsed +Python object; if scalar values differ, also strip shell comments inside +``run: |`` block bodies before comparing. Exit code 0 = all OK, 1 = at +least one file has a real (non-comment) diff or an error. + +Usage: + python scripts/verify_comment_only_diff.py [--base REF] [--head REF] path ... + +Defaults: --base origin/main, --head HEAD. Paths are repo-relative. + +Example: + git diff --name-only origin/main..HEAD \\ + | xargs python scripts/verify_comment_only_diff.py --base origin/main +""" + +from __future__ import annotations + +import argparse +import ast +import difflib +import subprocess +import sys +from typing import Any + +import yaml + + +def _git_show(rev: str, path: str) -> str: + return subprocess.check_output( + ["git", "show", f"{rev}:{path}"], + text = True, + stderr = subprocess.DEVNULL, + ) + + +def _strip_docstrings(tree: ast.AST) -> ast.AST: + """Remove every string-literal docstring (Module / FunctionDef / + AsyncFunctionDef / ClassDef). Empty body becomes ``pass`` so + ast.unparse stays valid.""" + for node in ast.walk(tree): + if isinstance( + node, + (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef), + ): + body = getattr(node, "body", None) + if not body: + continue + first = body[0] + if ( + isinstance(first, ast.Expr) + and isinstance(first.value, ast.Constant) + and isinstance(first.value.value, str) + ): + node.body = body[1:] + if not node.body: + node.body = [ast.Pass()] + return tree + + +def _normalize_py(src: str) -> str: + tree = ast.parse(src) + tree = _strip_docstrings(tree) + return ast.unparse(tree) + + +def _strip_shell_comments(s: str) -> str: + """Strip pure-comment lines and inline trailing comments from a shell + snippet, then collapse runs of blank lines. Heuristic only: leaves a + line untouched if it has an odd quote count (open string).""" + out = [] + for line in s.splitlines(): + stripped = line.lstrip() + if stripped.startswith("#"): + continue + has_single = line.count("'") % 2 == 0 + has_double = line.count('"') % 2 == 0 + if has_single and has_double: + idx = line.find(" #") + if idx >= 0: + line = line[:idx].rstrip() + out.append(line) + norm = [] + prev_blank = False + for line in out: + if line.strip() == "": + if prev_blank: + continue + prev_blank = True + else: + prev_blank = False + norm.append(line) + return "\n".join(norm).strip() + + +def _normalize_yaml_run_strings(obj: Any) -> Any: + """Walk the parsed YAML object; for any multi-line string (i.e. a + ``run: |`` script body), strip shell comments. Returns a normalised + copy.""" + if isinstance(obj, dict): + return {k: _normalize_yaml_run_strings(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_normalize_yaml_run_strings(x) for x in obj] + if isinstance(obj, str) and "\n" in obj: + return _strip_shell_comments(obj) + return obj + + +def _walk_yaml_diff(b: Any, a: Any, prefix: str = "") -> None: + """Print a path-keyed summary of the first structural / scalar diff.""" + if type(b) is not type(a): + print( + f" type-diff at {prefix or '/'}: " + f"{type(b).__name__} -> {type(a).__name__}", + ) + return + if isinstance(b, dict): + keys = sorted((set(b.keys()) | set(a.keys())), key = lambda x: str(x)) + for k in keys: + if k not in b: + print(f" added key {prefix}/{k}") + elif k not in a: + print(f" removed key {prefix}/{k}") + else: + _walk_yaml_diff(b[k], a[k], f"{prefix}/{k}") + elif isinstance(b, list): + if len(b) != len(a): + print( + f" list len at {prefix or '/'}: " f"{len(b)} -> {len(a)}", + ) + for i, (bi, ai) in enumerate(zip(b, a)): + _walk_yaml_diff(bi, ai, f"{prefix}[{i}]") + elif b != a: + bs = repr(b)[:300] + as_ = repr(a)[:300] + print(f" scalar at {prefix or '/'}:") + print(f" before: {bs}") + print(f" after: {as_}") + + +def _verify_python(path: str, before: str, after: str) -> bool: + try: + norm_before = _normalize_py(before) + norm_after = _normalize_py(after) + except SyntaxError as exc: + print(f"FAIL {path}: SyntaxError parsing -- {exc}") + return False + if norm_before == norm_after: + print(f"OK {path} (AST identical after docstring strip)") + return True + diff = list( + difflib.unified_diff( + norm_before.splitlines(), + norm_after.splitlines(), + fromfile = f"{path}@before", + tofile = f"{path}@after", + n = 2, + ) + ) + print(f"FAIL {path}: AST differs after docstring strip:") + for line in diff[:40]: + print(f" {line}") + return False + + +def _verify_yaml(path: str, before: str, after: str) -> bool: + try: + raw_before = yaml.safe_load(before) + raw_after = yaml.safe_load(after) + except yaml.YAMLError as exc: + print(f"FAIL {path}: YAML parse error -- {exc}") + return False + if raw_before == raw_after: + print(f"OK {path} (YAML parsed object identical)") + return True + norm_before = _normalize_yaml_run_strings(raw_before) + norm_after = _normalize_yaml_run_strings(raw_after) + if norm_before == norm_after: + print( + f"OK {path} (YAML parsed object identical after " + f"stripping shell comments from run: bodies)", + ) + return True + print( + f"FAIL {path}: YAML parsed objects still differ after stripping " + f"shell comments from `run:` bodies.", + ) + _walk_yaml_diff(norm_before, norm_after) + return False + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description = "Verify each path's diff between BASE and HEAD is " + "strictly comments / docstrings.", + ) + parser.add_argument("--base", default = "origin/main", help = "base git ref") + parser.add_argument("--head", default = "HEAD", help = "head git ref") + parser.add_argument("paths", nargs = "+", help = "repo-relative paths") + args = parser.parse_args(argv) + + rc = 0 + print(f"Comparing {len(args.paths)} files: {args.base} vs {args.head}\n") + for path in args.paths: + try: + before = _git_show(args.base, path) + after = _git_show(args.head, path) + except subprocess.CalledProcessError as exc: + print(f"SKIP {path}: {exc}") + continue + + if path.endswith(".py"): + if not _verify_python(path, before, after): + rc = 1 + elif path.endswith((".yml", ".yaml")): + if not _verify_yaml(path, before, after): + rc = 1 + else: + print(f"NOTE {path}: not .py or .yaml -- skipped automated check.") + + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/studio/backend/core/inference/external_provider.py b/studio/backend/core/inference/external_provider.py new file mode 100644 index 0000000000..de5f5c5500 --- /dev/null +++ b/studio/backend/core/inference/external_provider.py @@ -0,0 +1,2973 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Async HTTP client for proxying chat completions to external LLM providers. + +Most registry providers expose OpenAI-compatible /v1/chat/completions endpoints; +Anthropic uses native Messages API with translation in this client. +""" + +import json as _json +import re +from typing import Any, AsyncGenerator, Literal, NamedTuple, Optional + +import httpx +import structlog + +# Use structlog so INFO-level diagnostics actually surface in the +# studio backend's JSON log stream. The stdlib root logger defaults to +# WARNING and is not configured with handlers, so plain +# `logging.getLogger(__name__).info(...)` was being silently dropped — +# only WARNING/ERROR made it through (because they bypassed the root +# level threshold via uvicorn's stderr capture). All existing call +# sites use printf-style positional args, which structlog accepts. +logger = structlog.get_logger(__name__) + +# 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): + prefixes: tuple[str, ...] + kind: Literal["adaptive", "manual"] + efforts: tuple[str, ...] + + +_ANTHROPIC_THINKING_SPECS = ( + _AnthropicThinkingSpec( + prefixes = ("claude-opus-4-7",), + kind = "adaptive", + efforts = ("none", "low", "medium", "high", "xhigh", "max"), + ), + _AnthropicThinkingSpec( + prefixes = ("claude-opus-4-6", "claude-sonnet-4-6"), + kind = "adaptive", + efforts = ("none", "low", "medium", "high", "xhigh", "max"), + ), + _AnthropicThinkingSpec( + prefixes = ("claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"), + kind = "manual", + efforts = ("none", "low", "medium", "high"), + ), +) + + +def _anthropic_thinking_spec(model: str) -> Optional[_AnthropicThinkingSpec]: + for spec in _ANTHROPIC_THINKING_SPECS: + if model.startswith(spec.prefixes): + return spec + return None + + +class _MistralThinkingSpec(NamedTuple): + models: tuple[str, ...] + style: Literal["prompt_mode", "reasoning_effort", "disabled"] + efforts: tuple[str, ...] = () + + +_MISTRAL_THINKING_SPECS = ( + _MistralThinkingSpec( + models = ("magistral-medium-latest",), + style = "prompt_mode", + ), + _MistralThinkingSpec( + models = ("mistral-small-latest", "mistral-vibe-cli-latest"), + style = "reasoning_effort", + efforts = ("none", "high"), + ), +) + +_OPENROUTER_MANDATORY_REASONING_MODELS = frozenset( + { + "~google/gemini-pro-latest", + "baidu/cobuddy:free", + "inclusionai/ring-2.6-1t:free", + "deepseek/deepseek-r1", + } +) + + +def _mistral_thinking_spec(model: str) -> _MistralThinkingSpec: + for spec in _MISTRAL_THINKING_SPECS: + if model in spec.models: + return spec + return _MistralThinkingSpec(models = (), style = "disabled") + + +def _apply_mistral_reasoning_controls( + body: dict[str, Any], + model: str, + enable_thinking: Optional[bool], + reasoning_effort: Optional[str], +) -> None: + """ + Translate generic reasoning controls into Mistral's model-specific shape. + + Current contract: + - magistral-medium-latest: baseline (no extra field) or + `prompt_mode="reasoning"` for the explicit reasoning mode. + - mistral-small-latest / mistral-vibe-cli-latest: + `reasoning_effort` in {"none", "high"}. + - all other tested Mistral models: no reasoning/thinking params. + """ + model_for_matching = model.rsplit("/", 1)[-1].strip().lower() + spec = _mistral_thinking_spec(model_for_matching) + body.pop("prompt_mode", None) + body.pop("reasoning_effort", None) + + if spec.style == "prompt_mode": + # Magistral baseline is already reasoning-capable. The explicit + # prompt_mode path is only used for the "high" UI selection. + if enable_thinking is True or reasoning_effort == "high": + body["prompt_mode"] = "reasoning" + return + + if spec.style == "reasoning_effort": + if reasoning_effort in spec.efforts: + body["reasoning_effort"] = reasoning_effort + elif enable_thinking is False: + body["reasoning_effort"] = "none" + elif enable_thinking is True: + body["reasoning_effort"] = "high" + + +# Shared client reused across all requests for HTTP connection pooling. +# Auth headers and timeouts are passed per-request, so a single client +# handles every provider without storing credentials. +_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.""" + + def __init__( + self, + provider_type: str, + base_url: str, + api_key: str, + timeout: float = 120.0, + ): + self.provider_type = provider_type + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self._timeout = httpx.Timeout(timeout, connect = 10.0) + # Separate timeout for SSE streams: reasoning-heavy providers + # (Anthropic Opus 4.7 with adaptive thinking, OpenAI gpt-5.x via + # /v1/responses) can pause for tens of seconds between bytes + # while the model is internally thinking. httpx's read timeout is + # the *gap* between successive reads, not a wall clock — so + # disabling it lets long thinks complete without cutting the + # stream prematurely. connect/write/pool keep the 10s / 120s + # bounds so genuine network failures still surface. + self._stream_timeout = httpx.Timeout(timeout, connect = 10.0, read = None) + + def _auth_headers(self) -> dict[str, str]: + """Build authentication headers using the provider's registry config.""" + from core.inference.providers import get_provider_info + + provider_info = get_provider_info(self.provider_type) or {} + auth_header = provider_info.get("auth_header", "Authorization") + auth_prefix = provider_info.get("auth_prefix", "Bearer ") + + 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 + + def _is_openai_compatible(self) -> bool: + """Return False for providers that need request/response translation (e.g. Anthropic).""" + from core.inference.providers import get_provider_info + + info = get_provider_info(self.provider_type) or {} + return info.get("openai_compatible", True) + + async def stream_chat_completion( + self, + messages: list[dict[str, Any]], + model: str, + temperature: float = 0.7, + top_p: float = 0.95, + max_tokens: Optional[int] = None, + presence_penalty: float = 0.0, + 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]: + """ + Yield OpenAI-format SSE lines from the external provider. + + For OpenAI-compatible providers, lines are forwarded verbatim. + For Anthropic, the native Messages API SSE is translated to OpenAI format. + + ``top_k`` and ``presence_penalty`` are forwarded only when the caller + supplies a value the provider accepts — the frontend's + provider-capability map already filters these per provider, so we + treat them as opt-in here. + """ + if not self._is_openai_compatible(): + async for line in self._stream_anthropic( + messages, + model, + temperature, + top_p, + max_tokens, + top_k, + enable_thinking, + reasoning_effort, + enabled_tools, + enable_prompt_caching, + ): + yield line + return + + # OpenAI moved their flagship models (gpt-5.x) off /v1/chat/completions + # — those endpoints return 404 with "This is not a chat model" for the + # new families. Route all OpenAI traffic through /v1/responses instead; + # we translate the Responses SSE back into Chat Completions chunks so + # the frontend stays endpoint-agnostic. + if self.provider_type == "openai": + async for line in self._stream_openai_responses( + messages, + model, + temperature, + top_p, + 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 + + body: dict[str, Any] = { + "model": model, + "messages": messages, + "stream": stream, + "temperature": temperature, + "top_p": top_p, + "presence_penalty": presence_penalty, + } + if max_tokens is not None: + # OpenAI newer models (gpt-4o, gpt-5.x) reject max_tokens + if self.provider_type == "openai": + body["max_completion_tokens"] = max_tokens + else: + body["max_tokens"] = max_tokens + + # Strip body fields a provider's registry entry declares unusable — + # reasoning-class models that lock these to fixed defaults (e.g. + # Kimi k2.5/k2.6 only accept temperature=1, top_p=1) 400 otherwise. + # The frontend capability map already hides the matching sliders; + # this is the matching guard for the pydantic default that the + # route layer would otherwise still fill in. + 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) + + # Kimi (kimi-k2.6, kimi-k2-thinking) accepts a boolean thinking toggle + # via a top-level `thinking` field (the docs show it nested under + # extra_body, but that is an OpenAI Python SDK convention; on the + # wire it merges into the request body). + # - kimi-k2.6 defaults to thinking enabled; clients can pass + # {"type": "disabled"} to suppress it. + # - kimi-k2-thinking is always on; we never send disabled there. + # `keep: all` retains every thinking chunk through the stream, which + # is what we need so our frontend can wrap reasoning_content into + # the chat reasoning panel. + if self.provider_type == "kimi" and enable_thinking is not None: + if model == "kimi-k2-thinking": + # Always on; ignore client toggle to avoid an API-level reject. + pass + elif enable_thinking: + body["thinking"] = {"type": "enabled", "keep": "all"} + else: + body["thinking"] = {"type": "disabled"} + elif self.provider_type == "mistral": + _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 + # underlying model actually supports reasoning, and silently + # no-ops for ones that don't. Documented at + # https://openrouter.ai/docs/guides/best-practices/reasoning-tokens + # Shape: `reasoning: {enabled?: bool, effort?: low|medium|high, + # max_tokens?: N, exclude?: bool}` with effort and max_tokens + # mutually exclusive. We forward either an effort level (when + # the user picked one) or a bare {enabled: true}. A small set of + # known routes rejects explicit disable with 400 ("Reasoning is + # mandatory for this endpoint ..."), so only those omit "off". + if self.provider_type == "openrouter": + normalized_or_model = model.strip().lower() + if reasoning_effort in ("low", "medium", "high"): + body["reasoning"] = {"effort": reasoning_effort} + elif enable_thinking is True: + body["reasoning"] = {"enabled": True} + elif enable_thinking is False: + if normalized_or_model in _OPENROUTER_MANDATORY_REASONING_MODELS: + body.pop("reasoning", None) + 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)", + url, + self.provider_type, + model, + ) + + 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( + "External provider returned %d: %s", + response.status_code, + error_text[:500], + ) + yield _error_sse_line( + response.status_code, error_text, self.provider_type + ) + return + + # NOTE: manual __anext__ loop instead of `async for` is intentional. + # On Python 3.13 + httpcore 1.0.x, `async for` auto-calls aclose() on + # early exit (break/return/GeneratorExit) BEFORE our finally block runs. + # That propagates GeneratorExit into PoolByteStream.__aiter__() while it + # calls `await self.aclose()` inside `with AsyncShieldCancellation()`, + # triggering "RuntimeError: async generator ignored GeneratorExit". + # Fix: call response.aclose() FIRST (sets PoolByteStream._closed=True), + # then lines_gen.aclose() is a no-op and GeneratorExit re-raises cleanly. + lines_gen = response.aiter_lines().__aiter__() + # Best-effort diagnostics for the default OAI-compat path. Without + # this, OpenRouter mid-stream errors (200 OK + error event in the + # SSE body) and OpenRouter-router model selection were invisible + # in the backend logs — the user only saw "Provider returned + # 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: + 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 == "[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) + except Exception: + parsed = None + if isinstance(parsed, dict): + # Mid-stream provider error event. OpenRouter + # in particular returns 200 then surfaces the + # actual failure as an SSE error event. + if "error" in parsed: + event_counts["error"] = ( + event_counts.get("error", 0) + 1 + ) + logger.warning( + "%s SSE error event: %s", + self.provider_type, + parsed.get("error"), + ) + else: + event_counts["delta"] = ( + event_counts.get("delta", 0) + 1 + ) + # OpenRouter (and most OAI-compat providers) + # report the underlying model that handled + # the request in every chunk's `model` field. + # Latch the first non-empty value so the + # router-picked model surfaces in logs and + # is available to the proxy caller. + if chosen_model is None and isinstance( + 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, " + "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() + await lines_gen.aclose() + + except httpx.ConnectError as exc: + logger.error("Connection error to %s: %s", self.provider_type, exc) + yield _error_sse_line( + 502, + f"Failed to connect to {self.provider_type}: {exc}", + self.provider_type, + ) + except httpx.ReadTimeout as exc: + logger.error("Read timeout from %s: %s", self.provider_type, exc) + yield _error_sse_line( + 504, + f"Timeout waiting for {self.provider_type} response", + self.provider_type, + ) + except httpx.HTTPError as exc: + logger.error("HTTP error from %s: %s", self.provider_type, exc) + yield _error_sse_line( + 502, + f"Error communicating with {self.provider_type}: {exc}", + 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]], + model: str, + temperature: float, + top_p: float, + max_tokens: Optional[int], + 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. + + Anthropic SSE event types: + content_block_delta → OpenAI chunk with delta.content + message_delta → OpenAI chunk with finish_reason + message_stop → data: [DONE] + (all others skipped) + """ + import json as _json + + # Extract system prompt and translate image_url parts to Anthropic format + system: Optional[str] = None + filtered: list[dict[str, Any]] = [] + for msg in messages: + if msg.get("role") == "system": + content = msg.get("content", "") + system = ( + content + if isinstance(content, str) + else "\n".join( + p["text"] for p in content if p.get("type") == "text" + ) + ) + continue + + content = msg.get("content") + if isinstance(content, list): + # Translate OpenAI image_url parts → Anthropic native image format + anthropic_parts: list[dict[str, Any]] = [] + for part in content: + if part.get("type") == "text": + anthropic_parts.append({"type": "text", "text": part["text"]}) + elif part.get("type") == "image_url": + url = part.get("image_url", {}).get("url", "") + if url.startswith("data:"): + # data:image/png;base64, → split header and data + header, _, b64data = url.partition(",") + media_type = ( + header.split(";")[0].replace("data:", "") + or "image/jpeg" + ) + anthropic_parts.append( + { + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": b64data, + }, + } + ) + else: + # Remote URL — Anthropic supports url source type natively. + # See: https://docs.anthropic.com/en/docs/build-with-claude/vision#url-based-images + anthropic_parts.append( + { + "type": "image", + "source": { + "type": "url", + "url": url, + }, + } + ) + filtered.append({"role": msg["role"], "content": anthropic_parts}) + 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 + "stream": True, + } + 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: + 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 + if thinking_spec + else ("none", "low", "medium", "high") + ) + effort = reasoning_effort if reasoning_effort in allowed_efforts else None + # Claude 4.6 Opus/Sonnet accept top-tier adaptive effort as "max" only; + # "xhigh" is rejected (supported on Claude 4.7). Map our shared "xhigh" + # semantic to "max" for 4.6 outbound requests while still accepting + # both in ``allowed_efforts`` for persisted / cross-provider UI state. + if effort == "xhigh" and model.startswith( + ("claude-opus-4-6", "claude-sonnet-4-6") + ): + effort = "max" + if effort is None: + if enable_thinking is False: + effort = "none" + elif enable_thinking is True: + effort = "medium" + # Normalize one semantic Thinking control into Anthropic's two model-era + # APIs: adaptive effort on Claude 4.6/4.7, manual budget_tokens on 4.5. + if effort and effort != "none": + # Anthropic rejects top_k whenever thinking is enabled. + body.pop("top_k", None) + # 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." + # 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 + # adaptive-thinking docs) — without an explicit opt-in the + # API emits an empty thinking block plus a signature_delta, + # so our SSE handler would surface a stray + # and the reasoning panel would stay blank. Force + # "summarized" so 4.7 streams thinking_delta events like + # 4.6 does. On 4.6 / Sonnet 4.6 this is the default, so + # setting it explicitly is harmless. + body["thinking"] = {"type": "adaptive", "display": "summarized"} + # Per the Messages API reference, the effort knob for + # adaptive thinking lives under `output_config.effort` — + # NOT as a top-level field. Sending `effort: ...` directly + # produces a 400 "effort: Extra inputs are not permitted". + # Allowed values: low | medium | high | xhigh | max. See: + # https://platform.claude.com/docs/en/api/messages + body["output_config"] = {"effort": effort} + elif thinking_spec and thinking_spec.kind == "manual": + budget_tokens = {"low": 1024, "medium": 2048, "high": 4096}[effort] + body["thinking"] = { + "type": "enabled", + "budget_tokens": budget_tokens, + } + # Anthropic requires max_tokens to be strictly greater than + # thinking.budget_tokens on the manual-thinking path. + 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('/', '-')}" + + # Log the outgoing config keys (not the messages themselves) so we + # can prove which thinking/effort fields actually reached the wire. + # If Anthropic skips reasoning despite a configured effort, this + # tells us whether we sent the field or dropped it on the floor. + logger.info( + "Anthropic request shape (model=%s, has_thinking=%s, thinking=%s, " + "output_config=%s, temperature=%s, has_top_p=%s, has_top_k=%s, " + "max_tokens=%s)", + model, + "thinking" in body, + body.get("thinking"), + body.get("output_config"), + body.get("temperature"), + "top_p" in body, + "top_k" in body, + body.get("max_tokens"), + ) + + _finish_reason_map = { + "end_turn": "stop", + "max_tokens": "length", + "stop_sequence": "stop", + } + + 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 = request_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( + "Anthropic returned %d: %s", + response.status_code, + error_text[:500], + ) + yield _error_sse_line( + response.status_code, error_text, self.provider_type + ) + return + + # NOTE: same manual __anext__ loop as stream_chat_completion — see comment there. + lines_gen = response.aiter_lines().__aiter__() + thinking_open = False + # Diagnostic counters for the next time the user reports + # "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 = { + "id": completion_id, + "object": "chat.completion.chunk", + "choices": [ + { + "index": 0, + "delta": {"content": text}, + "finish_reason": None, + } + ], + } + 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:
+                            line = await lines_gen.__anext__()
+                        except StopAsyncIteration:
+                            break
+                        if not line or line.startswith("event:"):
+                            continue
+                        if not line.startswith("data:"):
+                            continue
+
+                        data_str = line[len("data:") :].strip()
+                        if not data_str:
+                            continue
+
+                        try:
+                            event = _json.loads(data_str)
+                        except _json.JSONDecodeError:
+                            continue
+
+                        event_type = event.get("type")
+                        if event_type == "content_block_delta":
+                            delta_kind = (event.get("delta") or {}).get("type")
+                            key = f"{event_type}:{delta_kind}"
+                        else:
+                            key = event_type or ""
+                        event_counts[key] = event_counts.get(key, 0) + 1
+
+                        # 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":
+                                # Anthropic streams extended-thinking content as
+                                # thinking_delta events on a separate content
+                                # block. Wrap as inline ... so
+                                # the frontend's parseAssistantContent lifts it
+                                # into the reasoning panel — same pattern as
+                                # the OpenAI Responses path.
+                                thinking_text = delta.get("thinking", "")
+                                if thinking_text:
+                                    if not thinking_open:
+                                        thinking_text = f"{thinking_text}"
+                                        thinking_open = True
+                                    yield _content_chunk(thinking_text)
+                            elif delta_type == "text_delta":
+                                # First text after a thinking block closes the
+                                #  tag we opened above. Anthropic emits
+                                # a content_block_stop between blocks, but
+                                # closing on the text_delta transition is more
+                                # forgiving if events arrive out of order.
+                                if thinking_open:
+                                    yield _content_chunk("")
+                                    thinking_open = False
+                                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":
+                            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:
+                                    yield _content_chunk("")
+                                    thinking_open = False
+                                chunk = {
+                                    "id": completion_id,
+                                    "object": "chat.completion.chunk",
+                                    "choices": [
+                                        {
+                                            "index": 0,
+                                            "delta": {},
+                                            "finish_reason": _finish_reason_map.get(
+                                                stop_reason, "stop"
+                                            ),
+                                        }
+                                    ],
+                                }
+                                yield f"data: {_json.dumps(chunk)}"
+
+                        elif event_type == "message_stop":
+                            if thinking_open:
+                                yield _content_chunk("")
+                                thinking_open = False
+                            yield "data: [DONE]"
+                            await (
+                                response.aclose()
+                            )  # set PoolByteStream._closed=True FIRST
+                            break
+                except GeneratorExit:
+                    await response.aclose()  # set PoolByteStream._closed=True FIRST
+                    await lines_gen.aclose()  # now safe — aclose() is a no-op
+                    raise
+                finally:
+                    # 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 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()
+                    await lines_gen.aclose()
+
+        except httpx.ConnectError as exc:
+            logger.error("Connection error to %s: %s", self.provider_type, exc)
+            yield _error_sse_line(
+                502,
+                f"Failed to connect to {self.provider_type}: {exc}",
+                self.provider_type,
+            )
+        except httpx.ReadTimeout as exc:
+            logger.error("Read timeout from %s: %s", self.provider_type, exc)
+            yield _error_sse_line(
+                504,
+                f"Timeout waiting for {self.provider_type} response",
+                self.provider_type,
+            )
+        except httpx.HTTPError as exc:
+            logger.error("HTTP error from %s: %s", self.provider_type, exc)
+            yield _error_sse_line(
+                502,
+                f"Error communicating with {self.provider_type}: {exc}",
+                self.provider_type,
+            )
+
+    async def _stream_openai_responses(
+        self,
+        messages: list[dict[str, Any]],
+        model: str,
+        temperature: float,
+        top_p: float,
+        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
+        into OpenAI Chat Completions chunk format.
+
+        The Responses API uses a different request shape (``input`` instead of
+        ``messages``, ``instructions`` for system prompts, ``max_output_tokens``
+        for the budget) and emits event-typed SSE frames (e.g.
+        ``response.output_text.delta``) rather than chat-completion chunks.
+        ``presence_penalty`` / ``top_k`` are not part of the Responses contract
+        and are dropped here intentionally.
+        """
+        import json as _json
+
+        # Split system messages out into a single `instructions` string and
+        # translate user/assistant messages into the Responses input shape.
+        instructions_parts: list[str] = []
+        input_items: list[dict[str, Any]] = []
+        for msg in messages:
+            role = msg.get("role")
+            content = msg.get("content", "")
+
+            if role == "system":
+                if isinstance(content, str):
+                    if content:
+                        instructions_parts.append(content)
+                elif isinstance(content, list):
+                    for part in content:
+                        if part.get("type") == "text" and part.get("text"):
+                            instructions_parts.append(part["text"])
+                continue
+
+            if isinstance(content, str):
+                input_items.append({"role": role, "content": content})
+                continue
+
+            if isinstance(content, list):
+                translated_parts: list[dict[str, Any]] = []
+                for part in content:
+                    part_type = part.get("type")
+                    if part_type == "text":
+                        translated_parts.append(
+                            {"type": "input_text", "text": part.get("text", "")}
+                        )
+                    elif part_type == "image_url":
+                        url = part.get("image_url", {}).get("url", "")
+                        if url:
+                            # Responses takes image_url as a flat string (both
+                            # https:// URLs and data: URLs are accepted).
+                            translated_parts.append(
+                                {"type": "input_image", "image_url": url}
+                            )
+                if translated_parts:
+                    input_items.append({"role": role, "content": translated_parts})
+
+        # NOTE: gpt-5.x / o3 / gpt-4.5 are reasoning-class models. They reject
+        # temperature and top_p with `Unsupported parameter` 400s on
+        # /v1/responses (and on /v1/chat/completions for the same families).
+        # The PROVIDER_REGISTRY['openai'] model_id_allowlist already scopes
+        # the picker to those families, so we never need to send sampling
+        # knobs here. ``reasoning.effort`` defaults to "medium" server-side
+        # if omitted — surface it in a future commit if a knob is wanted.
+        del temperature, top_p  # explicit drop — params are accepted for
+        # API symmetry with the other stream methods but not forwarded.
+
+        body: dict[str, Any] = {
+            "model": model,
+            "input": input_items,
+            "stream": True,
+        }
+        # `summary: "auto"` is what makes /v1/responses emit reasoning
+        # summary events — without it OpenAI returns no thinking text on
+        # most reasoning models, the SSE handler has no 
+        # 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",
+            "medium",
+            "high",
+            "max",
+            "xhigh",
+        ):
+            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"}
+            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('/', '-')}"
+
+        logger.info("Proxying OpenAI Responses API to %s (model=%s)", url, model)
+
+        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(
+                        "OpenAI Responses returned %d: %s",
+                        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
+                    )
+                    return
+
+                # NOTE: same manual __anext__ loop as stream_chat_completion —
+                # see comment there for the GeneratorExit / aclose ordering.
+                lines_gen = response.aiter_lines().__aiter__()
+                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:
+                        return ""
+                    if isinstance(payload, str):
+                        return payload
+                    if isinstance(payload, list):
+                        out: list[str] = []
+                        for item in payload:
+                            text = _extract_reasoning_text(item)
+                            if text:
+                                out.append(text)
+                        return "".join(out)
+                    if isinstance(payload, dict):
+                        # OpenAI responses may carry reasoning summaries in
+                        # different envelope fields across event variants.
+                        for key in ("text", "delta", "content", "summary"):
+                            if key in payload:
+                                text = _extract_reasoning_text(payload.get(key))
+                                if text:
+                                    return text
+                        if payload.get("type") == "summary_text":
+                            return _extract_reasoning_text(payload.get("text"))
+                    return ""
+
+                def _chunk_with_text(text: str) -> str:
+                    chunk = {
+                        "id": completion_id,
+                        "object": "chat.completion.chunk",
+                        "choices": [
+                            {
+                                "index": 0,
+                                "delta": {"content": text},
+                                "finish_reason": None,
+                            }
+                        ],
+                    }
+                    return f"data: {_json.dumps(chunk)}"
+
+                try:
+                    while True:
+                        try:
+                            line = await lines_gen.__anext__()
+                        except StopAsyncIteration:
+                            break
+                        if not line or line.startswith("event:"):
+                            continue
+                        if not line.startswith("data:"):
+                            continue
+
+                        data_str = line[len("data:") :].strip()
+                        if not data_str:
+                            continue
+                        if data_str == "[DONE]":
+                            if not done_emitted:
+                                yield "data: [DONE]"
+                                done_emitted = True
+                            break
+
+                        try:
+                            event = _json.loads(data_str)
+                        except _json.JSONDecodeError:
+                            continue
+
+                        event_type = event.get("type")
+
+                        if event_type == "response.output_text.delta":
+                            delta_text = event.get("delta", "")
+                            if delta_text:
+                                if reasoning_open:
+                                    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_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") == "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")
+                                )
+                                if summary_text and not reasoning_emitted:
+                                    if not reasoning_open:
+                                        summary_text = f"{summary_text}"
+                                        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)
+                            if reasoning_delta:
+                                if not reasoning_open:
+                                    reasoning_delta = f"{reasoning_delta}"
+                                    reasoning_open = True
+                                yield _chunk_with_text(reasoning_delta)
+                                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",
+                                "choices": [
+                                    {
+                                        "index": 0,
+                                        "delta": {},
+                                        "finish_reason": "stop",
+                                    }
+                                ],
+                            }
+                            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",
+                                "choices": [
+                                    {
+                                        "index": 0,
+                                        "delta": {},
+                                        "finish_reason": "length",
+                                    }
+                                ],
+                            }
+                            yield f"data: {_json.dumps(chunk)}"
+
+                        elif event_type in ("response.failed", "error"):
+                            # Surface the failure to the client; let the
+                            # outer route emit [DONE] as part of its cleanup.
+                            error_payload = event.get("response", {}).get(
+                                "error", {}
+                            ) or {
+                                "message": event.get("message", "Unknown error"),
+                                "code": event.get("code"),
+                            }
+                            yield _error_sse_line(
+                                502,
+                                _json.dumps(error_payload),
+                                self.provider_type,
+                            )
+                            break
+                except GeneratorExit:
+                    await response.aclose()
+                    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()
+
+        except httpx.ConnectError as exc:
+            logger.error("Connection error to %s: %s", self.provider_type, exc)
+            yield _error_sse_line(
+                502,
+                f"Failed to connect to {self.provider_type}: {exc}",
+                self.provider_type,
+            )
+        except httpx.ReadTimeout as exc:
+            logger.error("Read timeout from %s: %s", self.provider_type, exc)
+            yield _error_sse_line(
+                504,
+                f"Timeout waiting for {self.provider_type} response",
+                self.provider_type,
+            )
+        except httpx.HTTPError as exc:
+            logger.error("HTTP error from %s: %s", self.provider_type, exc)
+            yield _error_sse_line(
+                502,
+                f"Error communicating with {self.provider_type}: {exc}",
+                self.provider_type,
+            )
+
+    async def chat_completion(
+        self,
+        messages: list[dict[str, Any]],
+        model: str,
+        temperature: float = 0.7,
+        top_p: float = 0.95,
+        max_tokens: Optional[int] = None,
+        presence_penalty: float = 0.0,
+    ) -> dict[str, Any]:
+        """Non-streaming chat completion. Returns the full response dict.
+
+        Note: only valid for OpenAI-compatible providers. Anthropic requires its
+        own Messages API; use stream_chat_completion (with stream=False) instead
+        if a non-streaming Anthropic path is needed in the future.
+        """
+        body: dict[str, Any] = {
+            "model": model,
+            "messages": messages,
+            "stream": False,
+            "temperature": temperature,
+            "top_p": top_p,
+            "presence_penalty": presence_penalty,
+        }
+        if max_tokens is not None:
+            if self.provider_type == "openai":
+                body["max_completion_tokens"] = max_tokens
+            else:
+                body["max_tokens"] = max_tokens
+
+        response = await _http_client.post(
+            f"{self.base_url}/chat/completions",
+            json = body,
+            headers = self._auth_headers(),
+            timeout = self._timeout,
+        )
+        response.raise_for_status()
+        return response.json()
+
+    async def list_models(self) -> list[dict[str, Any]]:
+        """
+        Call GET /models on the provider to discover available models.
+
+        Returns a list of model dicts with at least 'id' and optionally
+        'created', 'owned_by', etc.
+
+        All supported providers expose a /models endpoint:
+        - OpenAI-compatible: standard {"data": [...]} response
+        - Anthropic: https://api.anthropic.com/v1/models — same {"data": [...]} shape
+        """
+        try:
+            response = await _http_client.get(
+                f"{self.base_url}/models",
+                headers = self._auth_headers(),
+                timeout = self._timeout,
+            )
+            response.raise_for_status()
+            data = response.json()
+            # OpenAI format: {"data": [{"id": "...", ...}, ...]}
+            models = data.get("data", [])
+            return models
+        except httpx.HTTPError as exc:
+            logger.error("Failed to list models from %s: %s", self.provider_type, exc)
+            raise
+
+    async def verify_models_endpoint_lightweight(self) -> None:
+        """
+        Confirm GET /models returns 200 without buffering the full response body.
+
+        Used for providers with enormous catalogs (e.g. OpenRouter, Hugging Face router)
+        where downloading the full JSON would be prohibitive.
+        """
+        url = f"{self.base_url}/models"
+        try:
+            async with _http_client.stream(
+                "GET",
+                url,
+                headers = self._auth_headers(),
+                timeout = self._timeout,
+            ) as response:
+                if response.status_code != 200:
+                    response.raise_for_status()
+                async for _chunk in response.aiter_bytes(chunk_size = 2048):
+                    break
+        except httpx.HTTPError as exc:
+            logger.error(
+                "Lightweight /models check failed for %s: %s",
+                self.provider_type,
+                exc,
+            )
+            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."""
+
+
+def _error_sse_line(status_code: int, message: str, provider_type: str) -> str:
+    """Format an error as an SSE data line in OpenAI error format."""
+    import json
+
+    error_obj = {
+        "error": {
+            "message": message,
+            "type": "provider_error",
+            "code": str(status_code),
+            "provider": provider_type,
+        }
+    }
+    return f"data: {json.dumps(error_obj)}"
diff --git a/studio/backend/core/inference/key_exchange.py b/studio/backend/core/inference/key_exchange.py
new file mode 100644
index 0000000000..f43bb16cf6
--- /dev/null
+++ b/studio/backend/core/inference/key_exchange.py
@@ -0,0 +1,127 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""
+RSA key pair for encrypting API keys in transit.
+
+The frontend encrypts API keys with the server's public key before
+including them in requests. The backend decrypts with its private key
+before forwarding to external providers.
+
+The key pair is generated at server startup and lives only in memory —
+it is regenerated on each restart. The frontend fetches the public key
+via GET /api/providers/public-key on load.
+"""
+
+import base64
+import hashlib
+import logging
+
+from cryptography.hazmat.primitives.asymmetric import rsa, padding
+from cryptography.hazmat.primitives import serialization, hashes
+
+logger = logging.getLogger(__name__)
+
+_private_key: rsa.RSAPrivateKey | None = None
+_public_key_pem: str | None = None
+_public_key_fingerprint: str | None = None
+
+
+def _compute_fingerprint(pem: str) -> str:
+    """SHA256 of the PEM bytes, truncated for log compactness."""
+    return hashlib.sha256(pem.encode("utf-8")).hexdigest()[:16]
+
+
+def init_key_pair() -> None:
+    """Generate an RSA-2048 key pair. Called once at server startup."""
+    global _private_key, _public_key_pem, _public_key_fingerprint
+    if _private_key is not None:
+        # Re-entry is suspicious — every fresh keypair invalidates all
+        # in-flight ciphertext encrypted against the previous public key.
+        # Log loudly so a regression that calls init twice is visible.
+        logger.warning(
+            "init_key_pair called again — replacing existing RSA keypair "
+            "(previous fingerprint=%s). Any frontend that cached the old "
+            "public key will start hitting decryption failures.",
+            _public_key_fingerprint,
+        )
+    _private_key = rsa.generate_private_key(
+        public_exponent = 65537,
+        key_size = 2048,
+    )
+    _public_key_pem = (
+        _private_key.public_key()
+        .public_bytes(
+            serialization.Encoding.PEM,
+            serialization.PublicFormat.SubjectPublicKeyInfo,
+        )
+        .decode("utf-8")
+    )
+    _public_key_fingerprint = _compute_fingerprint(_public_key_pem)
+    logger.info(
+        "RSA key pair generated for API key encryption (fingerprint=%s)",
+        _public_key_fingerprint,
+    )
+
+
+def get_public_key_fingerprint() -> str | None:
+    """Short SHA256 of the current public key PEM; None before init."""
+    return _public_key_fingerprint
+
+
+def get_public_key_pem() -> str:
+    """Return the PEM-encoded public key for the frontend."""
+    if _public_key_pem is None:
+        raise RuntimeError("Key pair not initialized. Call init_key_pair() first.")
+    return _public_key_pem
+
+
+def decrypt_api_key(encrypted_b64: str) -> str:
+    """
+    Decrypt an API key that was encrypted with the public key.
+
+    Args:
+        encrypted_b64: Base64-encoded RSA-OAEP ciphertext.
+
+    Returns:
+        The plaintext API key string.
+    """
+    if _private_key is None:
+        raise RuntimeError("Key pair not initialized. Call init_key_pair() first.")
+
+    try:
+        ciphertext = base64.b64decode(encrypted_b64)
+    except Exception as exc:
+        logger.warning(
+            "decrypt_api_key: base64 decode failed (input_len=%d, fingerprint=%s): %s: %s",
+            len(encrypted_b64),
+            _public_key_fingerprint,
+            type(exc).__name__,
+            exc,
+        )
+        raise
+
+    try:
+        plaintext = _private_key.decrypt(
+            ciphertext,
+            padding.OAEP(
+                mgf = padding.MGF1(algorithm = hashes.SHA256()),
+                algorithm = hashes.SHA256(),
+                label = None,
+            ),
+        )
+    except Exception as exc:
+        # Surface enough state to distinguish key mismatch (wrong public key
+        # used on encrypt) from a padding/algo mismatch or corrupted bytes.
+        # Expected ciphertext length for RSA-2048 is exactly 256 bytes.
+        logger.warning(
+            "decrypt_api_key: RSA decrypt failed (ciphertext_len=%d, expected=256, "
+            "fingerprint=%s, exc=%s): %s",
+            len(ciphertext),
+            _public_key_fingerprint,
+            type(exc).__name__,
+            exc,
+        )
+        raise
+
+    return plaintext.decode("utf-8")
diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py
index 35933e6685..7ef687035c 100644
--- a/studio/backend/core/inference/llama_cpp.py
+++ b/studio/backend/core/inference/llama_cpp.py
@@ -2367,8 +2367,20 @@ class LlamaCppBackend:
                 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}")
+                    # #5347 guard for paths that bypass detect_mmproj_file.
+                    from utils.models.model_config import (
+                        mmproj_matches_model_family,
+                    )
+
+                    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
@@ -3747,7 +3759,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/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
new file mode 100644
index 0000000000..143ced95f1
--- /dev/null
+++ b/studio/backend/core/inference/providers.py
@@ -0,0 +1,317 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""
+Static registry of supported external LLM providers.
+
+All providers expose OpenAI-compatible /v1/chat/completions endpoints
+with Bearer token authentication and SSE streaming support.
+"""
+
+import re
+from typing import Any
+
+PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
+    "openai": {
+        "display_name": "OpenAI",
+        "base_url": "https://api.openai.com/v1",
+        "default_models": [
+            "gpt-5.5",
+            "gpt-5.4",
+            "gpt-5.4-mini",
+            "o3",
+        ],
+        "supports_streaming": True,
+        "supports_vision": True,
+        "supports_tool_calling": True,
+        "auth_header": "Authorization",
+        "auth_prefix": "Bearer ",
+        # Keep the model picker scoped to the current generation. The remote
+        # /v1/models listing returns dozens of historical snapshots, fine-tunes
+        # and non-chat models (embeddings, TTS, image, moderation) that we
+        # never want to surface in the chat UI. Filtering here so backend
+        # is the single source of truth.
+        "model_id_allowlist": re.compile(r"^(gpt-5\.[345]|gpt-4\.5|o3)(?:[-.]|$)"),
+        # Hide dated snapshots and the retired plain gpt-5.3 id.
+        "model_id_denylist": re.compile(r"^(gpt-5\.3)$|-\d{4}-\d{2}-\d{2}$"),
+    },
+    "anthropic": {
+        "display_name": "Anthropic",
+        "base_url": "https://api.anthropic.com/v1",
+        "default_models": [
+            "claude-opus-4-7",
+            "claude-opus-4-6",
+            "claude-sonnet-4-6",
+            "claude-opus-4-5",
+            "claude-sonnet-4-5",
+            "claude-haiku-4-5",
+        ],
+        # Anthropic /v1/models returns dated snapshot ids alongside the
+        # canonical names (e.g. claude-3-5-sonnet-20241022). Hide the
+        # YYYYMMDD-suffixed variants from the picker — same intent as the
+        # OpenAI denylist, just a different date format (no dashes between
+        # year/month/day).
+        "model_id_denylist": re.compile(r"-\d{8}$"),
+        "supports_streaming": True,
+        "supports_vision": True,
+        "supports_tool_calling": False,
+        "auth_header": "x-api-key",
+        "auth_prefix": "",
+        "extra_headers": {
+            "anthropic-version": "2023-06-01",
+        },
+        "openai_compatible": False,
+        "notes": "Native Anthropic Messages API. Uses x-api-key header and /v1/messages endpoint with SSE translation.",
+    },
+    "gemini": {
+        "display_name": "Google Gemini",
+        "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
+        # Curated lineup — Google's /v1beta/openai/models returns dozens
+        # of historical / experimental / embedding ids. Cap to the current
+        # 3.x family plus the rolling `*-latest` aliases.
+        "default_models": [
+            "gemini-3.1-pro-preview",
+            "gemini-3.1-flash-lite",
+            "gemini-3-flash-preview",
+            "gemini-pro-latest",
+            "gemini-flash-latest",
+            "gemini-flash-lite-latest",
+        ],
+        "supports_streaming": True,
+        "supports_vision": True,
+        "supports_tool_calling": True,
+        "auth_header": "Authorization",
+        "auth_prefix": "Bearer ",
+        "notes": "OpenAI-compatible endpoint. API key from https://aistudio.google.com/apikey.",
+        "model_id_allowlist": re.compile(
+            r"^(gemini-3\.1-flash-lite|gemini-3-flash-preview|"
+            r"gemini-3\.1-pro-preview|gemini-pro-latest|"
+            r"gemini-flash-latest|gemini-flash-lite-latest)$"
+        ),
+    },
+    "deepseek": {
+        "display_name": "DeepSeek",
+        "base_url": "https://api.deepseek.com/v1",
+        "default_models": [
+            "deepseek-chat",
+            "deepseek-reasoner",
+        ],
+        "supports_streaming": True,
+        "supports_vision": False,
+        "supports_tool_calling": True,
+        "auth_header": "Authorization",
+        "auth_prefix": "Bearer ",
+        "notes": "OpenAI-compatible API. deepseek-chat = V3, deepseek-reasoner = R1 thinking mode.",
+    },
+    "mistral": {
+        "display_name": "Mistral AI",
+        "base_url": "https://api.mistral.ai/v1",
+        "default_models": [
+            "codestral-latest",
+            "devstral-latest",
+            "devstral-medium-latest",
+            "magistral-medium-latest",
+            "ministral-14b-latest",
+            "ministral-3b-latest",
+            "ministral-8b-latest",
+            "mistral-large-latest",
+            "mistral-medium-latest",
+            "mistral-small-latest",
+            "mistral-tiny-latest",
+            "mistral-vibe-cli-latest",
+        ],
+        "supports_streaming": True,
+        "supports_vision": True,
+        "supports_tool_calling": True,
+        "auth_header": "Authorization",
+        "auth_prefix": "Bearer ",
+        "model_id_allowlist": re.compile(
+            r"^(codestral-latest|devstral-latest|devstral-medium-latest|"
+            r"magistral-medium-latest|ministral-(?:14b|3b|8b)-latest|"
+            r"mistral-(?:large|medium|small|tiny)-latest|"
+            r"mistral-vibe-cli-latest)$"
+        ),
+    },
+    "kimi": {
+        "display_name": "Kimi",
+        "base_url": "https://api.moonshot.ai/v1",
+        # Current Kimi model lineup per the official docs:
+        #   https://platform.kimi.ai/docs/models
+        # Listing/overview endpoints used to enumerate them:
+        #   https://platform.kimi.ai/docs/api/list-models
+        #   https://platform.kimi.ai/docs/api/overview
+        # kimi-k2.6 and kimi-k2.5 are the two SoTA multimodal models we
+        # surface in the picker; everything else (moonshot-v1-*, dated
+        # k2 previews) is filtered out by model_id_allowlist below.
+        "default_models": [
+            "kimi-k2.6",
+            "kimi-k2.5",
+        ],
+        "supports_streaming": True,
+        "supports_vision": True,
+        "supports_tool_calling": True,
+        "auth_header": "Authorization",
+        "auth_prefix": "Bearer ",
+        "notes": "Moonshot API key. China: use base URL https://api.moonshot.cn/v1",
+        "model_id_allowlist": re.compile(r"^kimi-k2\.[56]$"),
+        # Both k2.6 and k2.5 are reasoning-class. The API rejects custom
+        # sampling: "invalid temperature: only 1 is allowed for this model"
+        # (and the same shape for top_p). Strip both fields from the
+        # outbound body so the server falls back to its required defaults.
+        "body_omit": ("temperature", "top_p"),
+    },
+    "qwen": {
+        "display_name": "Qwen",
+        "base_url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
+        "default_models": [
+            "qwen-plus",
+            "qwen-turbo",
+            "qwen-max",
+            "qwen2.5-72b-instruct",
+        ],
+        "supports_streaming": True,
+        "supports_vision": True,
+        "supports_tool_calling": True,
+        "auth_header": "Authorization",
+        "auth_prefix": "Bearer ",
+        "notes": "DashScope API key. China mainland: override base URL to https://dashscope.aliyuncs.com/compatible-mode/v1",
+    },
+    "huggingface": {
+        "display_name": "Hugging Face",
+        "base_url": "https://router.huggingface.co/v1",
+        # Seed the picker with a few popular ids so something is selectable
+        # before the live /v1/models call resolves. The remote listing is
+        # the source of truth — see model_list_mode below.
+        "default_models": [
+            "openai/gpt-oss-120b",
+            "deepseek-ai/DeepSeek-V3",
+            "meta-llama/Llama-3.3-70B-Instruct",
+            "Qwen/Qwen2.5-72B-Instruct",
+        ],
+        "supports_streaming": True,
+        "supports_vision": True,
+        "supports_tool_calling": True,
+        "auth_header": "Authorization",
+        "auth_prefix": "Bearer ",
+        "notes": (
+            "HF token from huggingface.co/settings/tokens. Uses the "
+            "OpenAI-compatible router at /v1/chat/completions; /v1/models "
+            "returns the cross-provider chat catalog. See "
+            "https://huggingface.co/docs/inference-providers/index."
+        ),
+        # /v1/models works on the HF router and returns the full chat-model
+        # catalog (state.org/model[:policy] ids). Switch to remote so users
+        # see live availability — the picker has a search box, and
+        # loadModels() merges defaults so default_models entries remain
+        # visible if the remote call fails.
+        "model_list_mode": "remote",
+        # Scope the catalog to first-party org repos we trust as primary
+        # sources. The HF /v1/models response is otherwise hundreds of
+        # ids long (community fine-tunes, mirrors, fp8 variants, etc.).
+        "model_id_allowlist": re.compile(
+            r"^(openai|deepseek-ai|google|meta-llama|Qwen|moonshotai|"
+            r"mistralai|zai-org)/"
+        ),
+        # Cap the post-filter list. /v1/models has no server-side limit
+        # or popularity sort, so this is just "first N matches" — pair it
+        # with the default_models seed so the most useful flagship ids
+        # 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",
+        # Curated list for Studio's picker (explicitly locked, not live /models).
+        "default_models": [
+            "openrouter/free",
+            "openai/gpt-4o",
+            "anthropic/claude-sonnet-4-5",
+            "google/gemini-2.5-flash",
+            "mistralai/mistral-large-2411",
+            "deepseek/deepseek-r1",
+            "mistralai/mistral-small-3.1-24b-instruct",
+            "perceptron/perceptron-mk1",
+            "inclusionai/ring-2.6-1t:free",
+            "google/gemini-3.1-flash-lite",
+            "baidu/cobuddy:free",
+            "openai/gpt-chat-latest",
+            "x-ai/grok-4.3",
+            "ibm-granite/granite-4.1-8b",
+            "openrouter/owl-alpha",
+            "poolside/laguna-xs.2:free",
+            "~google/gemini-pro-latest",
+            "~moonshotai/kimi-latest",
+        ],
+        "supports_streaming": True,
+        "supports_vision": True,
+        "supports_tool_calling": True,
+        "auth_header": "Authorization",
+        "auth_prefix": "Bearer ",
+        "extra_headers": {
+            "HTTP-Referer": "https://unsloth.ai",
+            "X-Title": "Unsloth Studio",
+        },
+        "notes": "Unified gateway to 300+ models across all major providers. HTTP-Referer and X-Title headers sent for attribution.",
+        "model_list_mode": "curated",
+    },
+}
+
+
+def get_provider_info(provider_type: str) -> dict[str, Any] | None:
+    """Return the registry entry for a provider type, or None if unknown."""
+    return PROVIDER_REGISTRY.get(provider_type)
+
+
+def get_base_url(provider_type: str) -> str | None:
+    """Return the default base URL for a provider type."""
+    info = PROVIDER_REGISTRY.get(provider_type)
+    return info["base_url"] if info else None
+
+
+def list_available_providers() -> list[dict[str, Any]]:
+    """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,
+                "display_name": info["display_name"],
+                "base_url": info["base_url"],
+                "default_models": info["default_models"],
+                "supports_streaming": info["supports_streaming"],
+                "supports_vision": info.get("supports_vision", False),
+                "supports_tool_calling": info.get("supports_tool_calling", False),
+                "model_list_mode": info.get("model_list_mode", "remote"),
+            }
+        )
+    return result
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 43bf0b69a7..cb94368064 100644
--- a/studio/backend/core/training/training.py
+++ b/studio/backend/core/training/training.py
@@ -219,6 +219,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 4443b733f7..ff890db8e0 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:
@@ -722,6 +777,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,
@@ -736,6 +797,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),
@@ -824,7 +887,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",
@@ -835,7 +908,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,
@@ -850,6 +923,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,
                 )
@@ -861,6 +939,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/main.py b/studio/backend/main.py
index 86990c1b71..489f52e6c4 100644
--- a/studio/backend/main.py
+++ b/studio/backend/main.py
@@ -120,6 +120,7 @@ from routes import (
     inference_router,
     inference_studio_router,
     models_router,
+    providers_router,
     training_history_router,
     training_router,
 )
@@ -222,6 +223,11 @@ async def lifespan(app: FastAPI):
 
     threading.Thread(target = _precache, daemon = True).start()
 
+    # Initialize RSA key pair for API key encryption (external providers)
+    from core.inference.key_exchange import init_key_pair
+
+    init_key_pair()
+
     if storage.ensure_default_admin():
         bootstrap_pw = storage.get_bootstrap_password()
         app.state.bootstrap_password = bootstrap_pw
@@ -293,7 +299,8 @@ def _build_csp(script_nonce: "str | None" = None) -> str:
         "https://cdn-avatars.huggingface.co; "
         "connect-src 'self' https://huggingface.co "
         "https://*.huggingface.co https://cdn-lfs.huggingface.co "
-        "https://cdn-lfs.hf.co https://hf.co https://*.hf.co; "
+        "https://cdn-lfs.hf.co https://hf.co https://*.hf.co "
+        "https://datasets-server.huggingface.co; "
         "style-src 'self' 'unsafe-inline'; "
         f"{script_src}; "
         "font-src 'self' data:; "
@@ -489,6 +496,7 @@ app.include_router(inference_studio_router, prefix = "/api/inference", tags = ["
 # so external tools (Open WebUI, SillyTavern, etc.) can use the
 # standard /v1/chat/completions path.
 app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"])
+app.include_router(providers_router, prefix = "/api/providers", tags = ["providers"])
 app.include_router(datasets_router, prefix = "/api/datasets", tags = ["datasets"])
 app.include_router(data_recipe_router, prefix = "/api/data-recipe", tags = ["data-recipe"])
 app.include_router(export_router, prefix = "/api/export", tags = ["export"])
diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py
index 09c13cb46a..6595ddb7c6 100644
--- a/studio/backend/models/inference.py
+++ b/studio/backend/models/inference.py
@@ -546,9 +546,11 @@ class ChatCompletionRequest(BaseModel):
         None,
         description = "[x-unsloth] Enable/disable thinking/reasoning mode for supported models",
     )
-    reasoning_effort: Optional[Literal["low", "medium", "high"]] = Field(
+    reasoning_effort: Optional[
+        Literal["none", "minimal", "low", "medium", "high", "max", "xhigh"]
+    ] = Field(
         None,
-        description = "[x-unsloth] Reasoning effort level ('low'|'medium'|'high') for Harmony-style reasoning models (e.g. gpt-oss). Overrides enable_thinking when the active model uses reasoning_effort style.",
+        description = "[x-unsloth] Reasoning effort level ('none'|'minimal'|'low'|'medium'|'high'|'max'|'xhigh'). OpenAI `/v1/responses` accepts model-dependent subsets; Anthropic adaptive thinking uses `max` as the top tier on Claude 4.6 Opus/Sonnet (inbound `xhigh` is mapped to `max`) and `xhigh` on Claude 4.7 Opus; local Harmony/gpt-oss templates support low|medium|high.",
     )
     preserve_thinking: Optional[bool] = Field(
         None,
@@ -585,6 +587,114 @@ class ChatCompletionRequest(BaseModel):
         description = "[x-unsloth] Per-request cancellation token. Frontend sends a fresh UUID per run so /inference/cancel matches one specific generation.",
     )
 
+    # ── External provider routing (x-unsloth extensions) ──────────
+    provider_id: Optional[str] = Field(
+        None,
+        description = "[x-unsloth] Saved provider config ID. If set with encrypted_api_key, routes to external LLM.",
+    )
+    provider_type: Optional[str] = Field(
+        None,
+        description = "[x-unsloth] Provider type (e.g. 'openai', 'mistral'). Used if provider_id is not set.",
+    )
+    external_model: Optional[str] = Field(
+        None,
+        description = "[x-unsloth] Model ID at the external provider.",
+    )
+    encrypted_api_key: Optional[str] = Field(
+        None,
+        description = "[x-unsloth] RSA-encrypted, base64-encoded API key for the external provider.",
+    )
+    provider_base_url: Optional[str] = Field(
+        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
new file mode 100644
index 0000000000..53ce981392
--- /dev/null
+++ b/studio/backend/models/providers.py
@@ -0,0 +1,130 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""
+Pydantic schemas for the external LLM providers API.
+"""
+
+from typing import Literal, Optional
+
+from pydantic import BaseModel, Field
+
+
+# ── Registry (static provider info) ───────────────────────────────
+
+
+class ProviderRegistryEntry(BaseModel):
+    """A supported provider type with its default configuration."""
+
+    provider_type: str = Field(
+        ..., description = "Provider identifier (e.g. 'openai', 'mistral')"
+    )
+    display_name: str = Field(..., description = "Human-readable provider name")
+    base_url: str = Field(..., description = "Default API base URL")
+    default_models: list[str] = Field(
+        default_factory = list, description = "Well-known model IDs for this provider"
+    )
+    supports_streaming: bool = Field(
+        True, description = "Whether this provider supports SSE streaming"
+    )
+    supports_vision: bool = Field(
+        False, description = "Whether this provider supports vision/image input"
+    )
+    supports_tool_calling: bool = Field(
+        False, description = "Whether this provider supports tool/function calling"
+    )
+    model_list_mode: Literal["remote", "curated"] = Field(
+        "remote",
+        description = "remote = fetch /models; curated = huge catalogs — UI uses defaults + manual IDs only",
+    )
+
+
+# ── Provider config CRUD ──────────────────────────────────────────
+
+
+class ProviderCreate(BaseModel):
+    """Request to create a saved provider configuration."""
+
+    provider_type: str = Field(..., description = "Provider type from the registry")
+    display_name: str = Field(
+        ..., description = "User-chosen label (e.g. 'My OpenAI Key')"
+    )
+    base_url: Optional[str] = Field(
+        None,
+        description = "Custom base URL (overrides registry default). Omit to use the default.",
+    )
+
+
+class ProviderUpdate(BaseModel):
+    """Request to update a saved provider configuration."""
+
+    display_name: Optional[str] = Field(None, description = "New display name")
+    base_url: Optional[str] = Field(None, description = "New base URL")
+    is_enabled: Optional[bool] = Field(
+        None, description = "Enable or disable this provider"
+    )
+
+
+class ProviderResponse(BaseModel):
+    """A saved provider configuration (returned by list/get endpoints)."""
+
+    id: str = Field(..., description = "Unique provider config ID")
+    provider_type: str = Field(..., description = "Provider type (e.g. 'openai')")
+    display_name: str = Field(..., description = "User-chosen label")
+    base_url: str = Field(..., description = "API base URL")
+    is_enabled: bool = Field(True, description = "Whether this provider is enabled")
+    created_at: str = Field(..., description = "ISO 8601 creation timestamp")
+    updated_at: str = Field(..., description = "ISO 8601 last-update timestamp")
+
+
+# ── Model listing ─────────────────────────────────────────────────
+
+
+class ProviderModelInfo(BaseModel):
+    """A model available from an external provider."""
+
+    id: str = Field(..., description = "Model ID as expected by the provider API")
+    display_name: str = Field("", description = "Human-readable model name")
+    context_length: Optional[int] = Field(
+        None, description = "Maximum context length in tokens"
+    )
+    owned_by: Optional[str] = Field(None, description = "Model owner/organization")
+
+
+class ProviderModelsRequest(BaseModel):
+    """Request to list models from an external provider."""
+
+    provider_type: str = Field(..., description = "Provider type from the registry")
+    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)"
+    )
+
+
+# ── Connection testing ────────────────────────────────────────────
+
+
+class ProviderTestRequest(BaseModel):
+    """Request to test connectivity to an external provider."""
+
+    provider_type: str = Field(..., description = "Provider type from the registry")
+    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)"
+    )
+
+
+class ProviderTestResult(BaseModel):
+    """Result of a provider connectivity test."""
+
+    success: bool = Field(..., description = "Whether the test succeeded")
+    message: str = Field(..., description = "Human-readable result message")
+    models_count: Optional[int] = Field(
+        None, description = "Number of models found (if test succeeded)"
+    )
diff --git a/studio/backend/models/training.py b/studio/backend/models/training.py
index 6b5e95e188..7c53b0fee5 100644
--- a/studio/backend/models/training.py
+++ b/studio/backend/models/training.py
@@ -130,20 +130,23 @@ class TrainingStartRequest(BaseModel):
     @field_validator("num_epochs")
     @classmethod
     def _check_num_epochs(cls, v: int) -> int:
+        # 0 is a sentinel meaning "use max_steps instead"; the frontend's
+        # steps-vs-epochs toggle sends it.
         if v is None:
             return 1
-        if v < 1 or v > _MAX_EPOCHS:
-            raise ValueError(f"num_epochs must be in [1, {_MAX_EPOCHS}] (got {v!r})")
+        if v < 0 or v > _MAX_EPOCHS:
+            raise ValueError(f"num_epochs must be in [0, {_MAX_EPOCHS}] (got {v!r})")
         return v
 
     @field_validator("max_steps")
     @classmethod
-    def _check_max_steps(cls, v):
+    def _check_max_steps(cls, v: Optional[int]) -> Optional[int]:
+        # 0 is the frontend's sentinel for "use num_epochs instead".
         if v is None:
             return v
-        if not isinstance(v, int) or v < 1 or v > _MAX_STEPS:
+        if not isinstance(v, int) or v < 0 or v > _MAX_STEPS:
             raise ValueError(
-                f"max_steps must be a positive int <= {_MAX_STEPS} (got {v!r})"
+                f"max_steps must be a non-negative int <= {_MAX_STEPS} (got {v!r})"
             )
         return v
 
@@ -158,7 +161,7 @@ class TrainingStartRequest(BaseModel):
 
     @field_validator("warmup_steps")
     @classmethod
-    def _check_warmup_steps(cls, v):
+    def _check_warmup_steps(cls, v: Optional[int]) -> Optional[int]:
         if v is None:
             return v
         if not isinstance(v, int) or v < 0 or v > _MAX_STEPS:
@@ -259,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")
@@ -321,6 +329,16 @@ class TrainingStartRequest(BaseModel):
         description = "Physical GPU indices to use, for example [0, 1]. Omit or pass [] to use automatic selection. Explicit gpu_ids are unsupported when the parent CUDA_VISIBLE_DEVICES uses UUID/MIG entries.",
     )
 
+    @model_validator(mode = "after")
+    def _check_steps_or_epochs(self) -> "TrainingStartRequest":
+        # num_epochs and max_steps each accept 0 as a "use the other one"
+        # sentinel. If both resolve to 0 there's nothing to train against.
+        if (self.max_steps is None or self.max_steps == 0) and self.num_epochs == 0:
+            raise ValueError(
+                "Either num_epochs or max_steps must be > 0; both cannot be 0."
+            )
+        return self
+
 
 class TrainingJobResponse(BaseModel):
     """Immediate response when training is initiated"""
diff --git a/studio/backend/requirements/studio.txt b/studio/backend/requirements/studio.txt
index 1bf751c368..96f8816b57 100644
--- a/studio/backend/requirements/studio.txt
+++ b/studio/backend/requirements/studio.txt
@@ -16,3 +16,5 @@ huggingface-hub==0.36.2
 structlog>=24.1.0
 diceware
 ddgs
+cryptography>=42.0.0
+httpx>=0.27.0
diff --git a/studio/backend/routes/__init__.py b/studio/backend/routes/__init__.py
index cf4586281b..62320b9084 100644
--- a/studio/backend/routes/__init__.py
+++ b/studio/backend/routes/__init__.py
@@ -14,6 +14,7 @@ from routes.auth import router as auth_router
 from routes.data_recipe import router as data_recipe_router
 from routes.export import router as export_router
 from routes.training_history import router as training_history_router
+from routes.providers import router as providers_router
 
 __all__ = [
     "training_router",
@@ -25,4 +26,5 @@ __all__ = [
     "data_recipe_router",
     "export_router",
     "training_history_router",
+    "providers_router",
 ]
diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py
index 4cf32b3228..4c0b5b7703 100644
--- a/studio/backend/routes/inference.py
+++ b/studio/backend/routes/inference.py
@@ -194,6 +194,11 @@ from models.inference import (
     AnthropicResponseTextBlock,
     AnthropicResponseToolUseBlock,
     AnthropicUsage,
+    CreateOpenAIContainerBody,
+    DeleteOpenAIContainerBody,
+    ListOpenAIContainersResponse,
+    OpenAIContainerRequest,
+    OpenAIContainerSummary,
 )
 from core.inference.anthropic_compat import (
     anthropic_messages_to_openai,
@@ -204,6 +209,11 @@ from core.inference.anthropic_compat import (
 )
 from auth.authentication import get_current_subject
 
+from core.inference.key_exchange import decrypt_api_key
+from core.inference.providers import get_provider_info, get_base_url
+from core.inference.external_provider import ExternalProviderClient
+from storage import providers_db
+
 import io
 import wave
 import base64
@@ -1464,6 +1474,345 @@ def _extract_content_parts(
     return system_prompt, chat_messages, first_image_b64
 
 
+# ── External provider proxy ──────────────────────────────────────
+
+
+def _build_external_messages(
+    messages: list,
+    supports_vision: bool,
+) -> list[dict]:
+    """
+    Convert ChatMessage list to OpenAI-compatible dicts for external providers.
+
+    - Vision providers: preserve multimodal content arrays (image_url parts intact).
+    - Non-vision providers: flatten to text-only (images silently dropped).
+    """
+    result = []
+    for msg in messages:
+        if isinstance(msg.content, str):
+            # Skip assistant messages with empty content (some providers reject them)
+            if msg.role == "assistant" and not msg.content.strip():
+                continue
+            result.append({"role": msg.role, "content": msg.content})
+        elif isinstance(msg.content, list):
+            if supports_vision:
+                parts = []
+                for part in msg.content:
+                    if part.type == "text":
+                        parts.append({"type": "text", "text": part.text})
+                    elif part.type == "image_url":
+                        parts.append(
+                            {
+                                "type": "image_url",
+                                "image_url": {"url": part.image_url.url},
+                            }
+                        )
+                result.append({"role": msg.role, "content": parts})
+            else:
+                # Non-vision provider — strip images, keep text only
+                text = "\n".join(p.text for p in msg.content if p.type == "text")
+                result.append({"role": msg.role, "content": text})
+    return result
+
+
+async def _proxy_to_external_provider(
+    payload: ChatCompletionRequest,
+    request: Request,
+) -> StreamingResponse:
+    """
+    Proxy a chat completion request to an external LLM provider.
+
+    Resolves provider config (from DB or registry), decrypts the API key,
+    and streams the response back in OpenAI SSE format.
+    """
+    # Resolve provider type and base URL
+    provider_type = payload.provider_type
+    base_url = payload.provider_base_url
+
+    if payload.provider_id:
+        config = providers_db.get_provider(payload.provider_id)
+        if config is None:
+            raise HTTPException(
+                status_code = 404,
+                detail = f"Provider config not found: {payload.provider_id}",
+            )
+        if not config["is_enabled"]:
+            raise HTTPException(
+                status_code = 400,
+                detail = f"Provider '{config['display_name']}' is disabled.",
+            )
+        provider_type = provider_type or config["provider_type"]
+        base_url = base_url or config["base_url"]
+
+    if not provider_type:
+        raise HTTPException(
+            status_code = 400,
+            detail = "Either provider_id or provider_type is required for external provider routing.",
+        )
+
+    # Fall back to registry default base URL
+    if not base_url:
+        base_url = get_base_url(provider_type)
+    if not base_url:
+        raise HTTPException(
+            status_code = 400,
+            detail = f"Unknown provider type: {provider_type}",
+        )
+
+    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":
+        raise HTTPException(
+            status_code = 400,
+            detail = "external_model is required when using an external provider.",
+        )
+
+    # Build messages preserving multimodal content for vision-capable providers
+    from core.inference.providers import get_provider_info as _get_provider_info
+
+    _pinfo = _get_provider_info(provider_type) or {}
+    _supports_vision = _pinfo.get("supports_vision", False)
+    chat_messages = _build_external_messages(payload.messages, _supports_vision)
+
+    client = ExternalProviderClient(
+        provider_type = provider_type,
+        base_url = base_url,
+        api_key = api_key,
+    )
+
+    async def _stream():
+        gen = client.stream_chat_completion(
+            messages = chat_messages,
+            model = model,
+            temperature = payload.temperature,
+            top_p = payload.top_p,
+            max_tokens = payload.max_tokens,
+            presence_penalty = payload.presence_penalty,
+            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:
+            sent_done = False
+            async for line in gen:
+                yield f"{line}\n\n"
+                if "[DONE]" in line:
+                    sent_done = True
+            if not sent_done:
+                yield "data: [DONE]\n\n"
+        except Exception as exc:
+            logger.error("external_provider.stream_error", error = str(exc))
+        finally:
+            try:
+                await gen.aclose()
+            except RuntimeError:
+                pass  # suppress httpcore asyncgen cleanup error (Python 3.13 + httpcore 1.0.x)
+            await client.close()
+
+    return StreamingResponse(
+        _stream(),
+        media_type = "text/event-stream",
+        headers = {
+            "Cache-Control": "no-cache",
+            "X-Accel-Buffering": "no",
+        },
+    )
+
+
+# ── 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,
@@ -1483,6 +1832,11 @@ async def openai_chat_completions(
     - GGUF models → llama-server via LlamaCppBackend
     - Other models → Unsloth/transformers via InferenceBackend
     """
+    # ── External provider routing ────────────────────────────────
+    # 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()
     using_gguf = llama_backend.is_loaded
 
diff --git a/studio/backend/routes/providers.py b/studio/backend/routes/providers.py
new file mode 100644
index 0000000000..acfaa6e427
--- /dev/null
+++ b/studio/backend/routes/providers.py
@@ -0,0 +1,346 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""
+API routes for external LLM provider management.
+
+Provides endpoints for:
+  - Discovering available provider types (registry)
+  - CRUD for saved provider configurations (no API keys stored)
+  - Fetching the RSA public key for API key encryption
+  - Testing provider connectivity
+  - Listing models from a provider
+"""
+
+import uuid
+import structlog
+from fastapi import APIRouter, Depends, HTTPException
+
+from auth.authentication import get_current_subject
+from core.inference.key_exchange import (
+    decrypt_api_key,
+    get_public_key_fingerprint,
+    get_public_key_pem,
+)
+from core.inference.providers import (
+    get_base_url,
+    get_provider_info,
+    list_available_providers,
+)
+from core.inference.external_provider import ExternalProviderClient
+from models.providers import (
+    ProviderCreate,
+    ProviderModelsRequest,
+    ProviderModelInfo,
+    ProviderResponse,
+    ProviderRegistryEntry,
+    ProviderTestRequest,
+    ProviderTestResult,
+    ProviderUpdate,
+)
+from storage import providers_db
+
+logger = structlog.get_logger(__name__)
+
+router = APIRouter()
+
+
+# ── Public key for API key encryption ─────────────────────────────
+
+
+@router.get("/public-key")
+async def get_public_key(
+    current_subject: str = Depends(get_current_subject),
+):
+    """Return the RSA public key PEM for client-side API key encryption.
+
+    The ``fingerprint`` field is a short SHA256 of the PEM and is meant
+    purely for diagnostics — a mismatch between what the frontend
+    captured at encrypt time and what the server reports here is a
+    clear signal that the keypair rotated mid-flight (e.g. the server
+    re-ran ``init_key_pair`` for any reason).
+    """
+    return {
+        "public_key": get_public_key_pem(),
+        "fingerprint": get_public_key_fingerprint(),
+    }
+
+
+# ── Provider registry (static) ───────────────────────────────────
+
+
+@router.get("/registry", response_model = list[ProviderRegistryEntry])
+async def list_registry(
+    current_subject: str = Depends(get_current_subject),
+):
+    """List all supported provider types with their default configurations."""
+    return list_available_providers()
+
+
+# ── Provider config CRUD ──────────────────────────────────────────
+
+
+@router.get("/", response_model = list[ProviderResponse])
+async def list_provider_configs(
+    current_subject: str = Depends(get_current_subject),
+):
+    """List all saved provider configurations."""
+    rows = providers_db.list_providers()
+    return [
+        ProviderResponse(
+            id = row["id"],
+            provider_type = row["provider_type"],
+            display_name = row["display_name"],
+            base_url = row["base_url"],
+            is_enabled = bool(row["is_enabled"]),
+            created_at = row["created_at"],
+            updated_at = row["updated_at"],
+        )
+        for row in rows
+    ]
+
+
+@router.post("/", response_model = ProviderResponse, status_code = 201)
+async def create_provider_config(
+    payload: ProviderCreate,
+    current_subject: str = Depends(get_current_subject),
+):
+    """Create a new saved provider configuration (no API key stored)."""
+    info = get_provider_info(payload.provider_type)
+    if info is None:
+        raise HTTPException(
+            status_code = 400,
+            detail = f"Unknown provider type: {payload.provider_type}. "
+            f"Use GET /api/providers/registry to see available types.",
+        )
+
+    provider_id = uuid.uuid4().hex[:16]
+    base_url = payload.base_url or info["base_url"]
+
+    providers_db.create_provider(
+        id = provider_id,
+        provider_type = payload.provider_type,
+        display_name = payload.display_name,
+        base_url = base_url,
+    )
+
+    row = providers_db.get_provider(provider_id)
+    return ProviderResponse(
+        id = row["id"],
+        provider_type = row["provider_type"],
+        display_name = row["display_name"],
+        base_url = row["base_url"],
+        is_enabled = bool(row["is_enabled"]),
+        created_at = row["created_at"],
+        updated_at = row["updated_at"],
+    )
+
+
+@router.put("/{provider_id}", response_model = ProviderResponse)
+async def update_provider_config(
+    provider_id: str,
+    payload: ProviderUpdate,
+    current_subject: str = Depends(get_current_subject),
+):
+    """Update a saved provider configuration."""
+    existing = providers_db.get_provider(provider_id)
+    if not existing:
+        raise HTTPException(status_code = 404, detail = "Provider not found")
+
+    updated = providers_db.update_provider(
+        id = provider_id,
+        display_name = payload.display_name,
+        base_url = payload.base_url,
+        is_enabled = payload.is_enabled,
+    )
+    if not updated:
+        raise HTTPException(status_code = 400, detail = "No fields to update")
+
+    row = providers_db.get_provider(provider_id)
+    return ProviderResponse(
+        id = row["id"],
+        provider_type = row["provider_type"],
+        display_name = row["display_name"],
+        base_url = row["base_url"],
+        is_enabled = bool(row["is_enabled"]),
+        created_at = row["created_at"],
+        updated_at = row["updated_at"],
+    )
+
+
+@router.delete("/{provider_id}", status_code = 204)
+async def delete_provider_config(
+    provider_id: str,
+    current_subject: str = Depends(get_current_subject),
+):
+    """Delete a saved provider configuration."""
+    deleted = providers_db.delete_provider(provider_id)
+    if not deleted:
+        raise HTTPException(status_code = 404, detail = "Provider not found")
+
+
+# ── Test connectivity ─────────────────────────────────────────────
+
+
+@router.post("/test", response_model = ProviderTestResult)
+async def test_provider(
+    payload: ProviderTestRequest,
+    current_subject: str = Depends(get_current_subject),
+):
+    """
+    Test connectivity to an external provider.
+
+    Makes a lightweight GET /models call to verify the API key works.
+    The encrypted_api_key is decrypted server-side and never stored.
+    """
+    info = get_provider_info(payload.provider_type)
+    if info is None:
+        raise HTTPException(
+            status_code = 400,
+            detail = f"Unknown provider type: {payload.provider_type}",
+        )
+
+    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(
+        provider_type = payload.provider_type,
+        base_url = base_url,
+        api_key = api_key,
+        timeout = 15.0,
+    )
+
+    try:
+        if info.get("model_list_mode") == "curated":
+            await client.verify_models_endpoint_lightweight()
+            return ProviderTestResult(
+                success = True,
+                message = (
+                    "Connected successfully. Full model list is not fetched for this provider — "
+                    "use suggestions and manual model IDs in the dialog."
+                ),
+                models_count = None,
+            )
+        models = await client.list_models()
+        return ProviderTestResult(
+            success = True,
+            message = f"Connected successfully. Found {len(models)} model(s).",
+            models_count = len(models),
+        )
+    except Exception as exc:
+        logger.warning("Provider test failed for %s: %s", payload.provider_type, exc)
+        return ProviderTestResult(
+            success = False,
+            message = f"Connection failed: {exc}",
+            models_count = None,
+        )
+    finally:
+        await client.close()
+
+
+# ── List models from provider ─────────────────────────────────────
+
+
+@router.post("/models", response_model = list[ProviderModelInfo])
+async def list_provider_models(
+    payload: ProviderModelsRequest,
+    current_subject: str = Depends(get_current_subject),
+):
+    """
+    List models available from an external provider.
+
+    The encrypted_api_key is decrypted server-side and never stored.
+    """
+    info = get_provider_info(payload.provider_type)
+    if info is None:
+        raise HTTPException(
+            status_code = 400,
+            detail = f"Unknown provider type: {payload.provider_type}",
+        )
+
+    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 [
+            ProviderModelInfo(
+                id = m,
+                display_name = m,
+                context_length = None,
+                owned_by = None,
+            )
+            for m in info.get("default_models", [])
+        ]
+
+    base_url = payload.base_url or info["base_url"]
+    client = ExternalProviderClient(
+        provider_type = payload.provider_type,
+        base_url = base_url,
+        api_key = api_key,
+        timeout = 15.0,
+    )
+
+    try:
+        models = await client.list_models()
+        allow_prefixes = info.get("model_id_allow_prefixes")
+        if allow_prefixes is not None:
+            prefix_tuple = tuple(str(p) for p in allow_prefixes if str(p))
+            if prefix_tuple:
+                models = [m for m in models if m.get("id", "").startswith(prefix_tuple)]
+        allowlist = info.get("model_id_allowlist")
+        if allowlist is not None:
+            models = [m for m in models if allowlist.match(m.get("id", ""))]
+        deny_exact = info.get("model_id_deny_exact")
+        if deny_exact is not None:
+            deny_ids = {str(m) for m in deny_exact if str(m)}
+            if deny_ids:
+                models = [m for m in models if m.get("id", "") not in deny_ids]
+        denylist = info.get("model_id_denylist")
+        if denylist is not None:
+            models = [m for m in models if not denylist.search(m.get("id", ""))]
+        # Apply an optional cap after filtering so registry entries with a
+        # large remote catalog (e.g. HF Inference Providers) can stay
+        # picker-sized. No popularity sort happens server-side, so this is
+        # "first N matches" — pair with default_models for any must-have
+        # flagship ids.
+        limit = info.get("model_id_limit")
+        if isinstance(limit, int) and limit > 0:
+            models = models[:limit]
+        return [
+            ProviderModelInfo(
+                id = m.get("id", ""),
+                display_name = m.get("id", ""),
+                context_length = m.get("context_length") or m.get("context_window"),
+                owned_by = m.get("owned_by"),
+            )
+            for m in models
+        ]
+    except Exception as exc:
+        logger.error("Failed to list models from %s: %s", payload.provider_type, exc)
+        raise HTTPException(
+            status_code = 502,
+            detail = f"Failed to list models from {payload.provider_type}: {exc}",
+        )
+    finally:
+        await client.close()
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/storage/providers_db.py b/studio/backend/storage/providers_db.py
new file mode 100644
index 0000000000..ca47fcbd80
--- /dev/null
+++ b/studio/backend/storage/providers_db.py
@@ -0,0 +1,153 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""
+SQLite storage for external LLM provider configurations.
+
+Follows the same pattern as studio_db.py — module-level functions,
+raw sqlite3, WAL mode, per-function connections.
+
+NOTE: API keys are NOT stored here. They live only in the browser
+(localStorage) and are sent encrypted per-request.
+"""
+
+import logging
+import sqlite3
+import threading
+from datetime import datetime, timezone
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+from utils.paths import studio_db_path, ensure_dir
+
+_schema_lock = threading.Lock()
+_schema_ready = False
+
+
+def _ensure_schema(conn: sqlite3.Connection) -> None:
+    """Create the llm_providers table if it doesn't exist. Called once per process."""
+    conn.execute("PRAGMA journal_mode=WAL")
+    conn.execute(
+        """
+        CREATE TABLE IF NOT EXISTS llm_providers (
+            id TEXT NOT NULL PRIMARY KEY,
+            provider_type TEXT NOT NULL,
+            display_name TEXT NOT NULL,
+            base_url TEXT NOT NULL,
+            is_enabled INTEGER NOT NULL DEFAULT 1,
+            created_at TEXT NOT NULL,
+            updated_at TEXT NOT NULL
+        )
+        """
+    )
+
+
+def get_connection() -> sqlite3.Connection:
+    """Open studio.db with WAL mode, create table once per process."""
+    global _schema_ready
+    db_path = studio_db_path()
+    ensure_dir(db_path.parent)
+    conn = sqlite3.connect(str(db_path))
+    conn.row_factory = sqlite3.Row
+    if not _schema_ready:
+        with _schema_lock:
+            if not _schema_ready:
+                try:
+                    _ensure_schema(conn)
+                    _schema_ready = True
+                except Exception:
+                    conn.close()
+                    raise
+    return conn
+
+
+def create_provider(
+    id: str,
+    provider_type: str,
+    display_name: str,
+    base_url: str,
+) -> None:
+    """Insert a new provider configuration."""
+    now = datetime.now(timezone.utc).isoformat()
+    conn = get_connection()
+    try:
+        conn.execute(
+            """
+            INSERT INTO llm_providers (id, provider_type, display_name, base_url, created_at, updated_at)
+            VALUES (?, ?, ?, ?, ?, ?)
+            """,
+            (id, provider_type, display_name, base_url, now, now),
+        )
+        conn.commit()
+    finally:
+        conn.close()
+
+
+def update_provider(
+    id: str,
+    display_name: Optional[str] = None,
+    base_url: Optional[str] = None,
+    is_enabled: Optional[bool] = None,
+) -> bool:
+    """Update fields on an existing provider. Returns True if a row was updated."""
+    updates = []
+    params = []
+    if display_name is not None:
+        updates.append("display_name = ?")
+        params.append(display_name)
+    if base_url is not None:
+        updates.append("base_url = ?")
+        params.append(base_url)
+    if is_enabled is not None:
+        updates.append("is_enabled = ?")
+        params.append(1 if is_enabled else 0)
+    if not updates:
+        return False
+    updates.append("updated_at = ?")
+    params.append(datetime.now(timezone.utc).isoformat())
+    params.append(id)
+
+    conn = get_connection()
+    try:
+        cursor = conn.execute(
+            f"UPDATE llm_providers SET {', '.join(updates)} WHERE id = ?",
+            params,
+        )
+        conn.commit()
+        return cursor.rowcount > 0
+    finally:
+        conn.close()
+
+
+def delete_provider(id: str) -> bool:
+    """Delete a provider by ID. Returns True if a row was deleted."""
+    conn = get_connection()
+    try:
+        cursor = conn.execute("DELETE FROM llm_providers WHERE id = ?", (id,))
+        conn.commit()
+        return cursor.rowcount > 0
+    finally:
+        conn.close()
+
+
+def get_provider(id: str) -> Optional[dict]:
+    """Fetch a single provider by ID."""
+    conn = get_connection()
+    try:
+        row = conn.execute("SELECT * FROM llm_providers WHERE id = ?", (id,)).fetchone()
+        return dict(row) if row else None
+    finally:
+        conn.close()
+
+
+def list_providers() -> list[dict]:
+    """List all provider configurations, ordered by creation time."""
+    conn = get_connection()
+    try:
+        rows = conn.execute(
+            "SELECT * FROM llm_providers ORDER BY created_at"
+        ).fetchall()
+        return [dict(row) for row in rows]
+    finally:
+        conn.close()
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_anthropic_thinking_translation.py b/studio/backend/tests/test_anthropic_thinking_translation.py
new file mode 100644
index 0000000000..14f261ae6b
--- /dev/null
+++ b/studio/backend/tests/test_anthropic_thinking_translation.py
@@ -0,0 +1,404 @@
+# 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 Anthropic extended-thinking translation in
+external_provider.
+
+Covers:
+- Adaptive-mode request body nests effort under
+  ``output_config: {effort: ""}`` per the Messages API
+  reference (a top-level ``effort`` field 400s with
+  "effort: Extra inputs are not permitted").
+- Streaming SSE: ``content_block_delta`` with
+  ``delta.type == "thinking_delta"`` is translated into inline
+  ``...`` chat-completion chunks so the frontend's
+  reasoning-panel pipeline lifts it correctly.
+- The ```` tag closes when the first ``text_delta`` arrives,
+  on ``content_block_stop``, on ``message_delta``, or on
+  ``message_stop``.
+- Thinking is paired with ``temperature=1`` and no ``top_p`` /
+  ``top_k`` on the wire (Anthropic extended-thinking contract).
+"""
+
+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:
+    """Serialize a list of Messages-API event dicts as an SSE byte stream."""
+    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 _payloads_from_lines(lines: list[str]) -> list:
+    out = []
+    for line in lines:
+        if not line.startswith("data:"):
+            continue
+        raw = line[len("data:") :].strip()
+        if not raw:
+            continue
+        if raw == "[DONE]":
+            out.append("[DONE]")
+        else:
+            out.append(json.loads(raw))
+    return out
+
+
+def test_adaptive_thinking_body_uses_output_config_effort_shape(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        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-6",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = 4096,
+            top_k = None,
+            enable_thinking = None,
+            reasoning_effort = "medium",
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    body = captured["body"]
+    # display=summarized is set explicitly so Opus 4.7 (which defaults to
+    # "omitted") still emits thinking_delta events for the reasoning panel.
+    assert body["thinking"] == {"type": "adaptive", "display": "summarized"}
+    # Documented shape: effort is nested under output_config.
+    # A top-level `effort` field produces a 400:
+    #   "effort: Extra inputs are not permitted".
+    assert body["output_config"] == {"effort": "medium"}
+    assert "effort" not in body
+    # Extended-thinking contract: temperature=1, no top_p / top_k.
+    assert body["temperature"] == 1
+    assert "top_p" not in body
+    assert "top_k" not in body
+
+
+def test_adaptive_thinking_maps_xhigh_to_max_on_claude_4_6(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        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-sonnet-4-6",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = 4096,
+            top_k = None,
+            enable_thinking = None,
+            reasoning_effort = "xhigh",
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    assert captured["body"]["output_config"] == {"effort": "max"}
+
+
+def test_adaptive_thinking_keeps_max_on_claude_4_6(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        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-6",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = 4096,
+            top_k = None,
+            enable_thinking = None,
+            reasoning_effort = "max",
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    assert captured["body"]["output_config"] == {"effort": "max"}
+
+
+def test_adaptive_thinking_keeps_xhigh_on_claude_4_7(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        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,
+            top_k = None,
+            enable_thinking = None,
+            reasoning_effort = "xhigh",
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    body = captured["body"]
+    assert body["output_config"] == {"effort": "xhigh"}
+    assert "effort" not in body
+
+
+def test_manual_thinking_body_uses_budget_tokens_on_4_5(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["body"] = json.loads(request.content.decode("utf-8"))
+        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-5",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = 1024,
+            top_k = None,
+            enable_thinking = None,
+            reasoning_effort = "high",
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    body = captured["body"]
+    assert body["thinking"] == {"type": "enabled", "budget_tokens": 4096}
+    # max_tokens must be strictly greater than budget_tokens; we shipped 1024
+    # and budget is 4096, so the wrapper should bump max_tokens.
+    assert body["max_tokens"] > body["thinking"]["budget_tokens"]
+    # Manual-thinking path does not use output_config / effort — those are
+    # the adaptive-mode controls (Claude 4.6 / 4.7).
+    assert "effort" not in body
+    assert "output_config" not in body
+
+
+def test_thinking_delta_wrapped_in_think_tags(monkeypatch):
+    def handler(request: httpx.Request) -> httpx.Response:
+        events = [
+            {
+                "type": "content_block_start",
+                "index": 0,
+                "content_block": {"type": "thinking", "thinking": "", "signature": ""},
+            },
+            {
+                "type": "content_block_delta",
+                "index": 0,
+                "delta": {"type": "thinking_delta", "thinking": "First "},
+            },
+            {
+                "type": "content_block_delta",
+                "index": 0,
+                "delta": {"type": "thinking_delta", "thinking": "I plan."},
+            },
+            {
+                "type": "content_block_delta",
+                "index": 0,
+                "delta": {"type": "signature_delta", "signature": "abc123"},
+            },
+            {"type": "content_block_stop", "index": 0},
+            {
+                "type": "content_block_start",
+                "index": 1,
+                "content_block": {"type": "text", "text": ""},
+            },
+            {
+                "type": "content_block_delta",
+                "index": 1,
+                "delta": {"type": "text_delta", "text": "Answer."},
+            },
+            {"type": "content_block_stop", "index": 1},
+            {"type": "message_delta", "delta": {"stop_reason": "end_turn"}},
+            {"type": "message_stop"},
+        ]
+        return httpx.Response(
+            200,
+            content = _anthropic_sse(events),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        lines = await _collect(
+            client._stream_anthropic(
+                messages = [{"role": "user", "content": "hi"}],
+                model = "claude-opus-4-6",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = 4096,
+                top_k = None,
+                enable_thinking = True,
+                reasoning_effort = None,
+            )
+        )
+        await client.close()
+        return lines
+
+    lines = _drive(run())
+    payloads = _payloads_from_lines(lines)
+
+    combined = "".join(
+        p["choices"][0]["delta"].get("content", "")
+        for p in payloads
+        if isinstance(p, dict) and p["choices"][0]["delta"]
+    )
+
+    # Reasoning text should be wrapped in ..., followed by the
+    # answer text, and the stream should terminate with [DONE].
+    assert "First I plan." in combined
+    assert combined.endswith("Answer.")
+    # signature_delta is intentionally dropped — no leaked signature text.
+    assert "abc123" not in combined
+    assert "[DONE]" in payloads
+
+
+def test_thinking_only_turn_closes_tag_without_text_delta(monkeypatch):
+    """display=omitted on Claude 4.7 emits a signature_delta and no text.
+
+    The  open is still triggered by the (synthetic) thinking_delta;
+    we want content_block_stop to close it cleanly so the tag never leaks
+    into the next chunk."""
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        events = [
+            {
+                "type": "content_block_start",
+                "index": 0,
+                "content_block": {"type": "thinking", "thinking": "", "signature": ""},
+            },
+            {
+                "type": "content_block_delta",
+                "index": 0,
+                "delta": {"type": "thinking_delta", "thinking": "internal"},
+            },
+            {"type": "content_block_stop", "index": 0},
+            {"type": "message_delta", "delta": {"stop_reason": "end_turn"}},
+            {"type": "message_stop"},
+        ]
+        return httpx.Response(
+            200,
+            content = _anthropic_sse(events),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        lines = await _collect(
+            client._stream_anthropic(
+                messages = [{"role": "user", "content": "hi"}],
+                model = "claude-opus-4-7",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = 4096,
+                top_k = None,
+                enable_thinking = True,
+                reasoning_effort = None,
+            )
+        )
+        await client.close()
+        return lines
+
+    payloads = _payloads_from_lines(_drive(run()))
+    combined = "".join(
+        p["choices"][0]["delta"].get("content", "")
+        for p in payloads
+        if isinstance(p, dict) and p["choices"][0]["delta"]
+    )
+    assert combined == "internal"
diff --git a/studio/backend/tests/test_desktop_auth.py b/studio/backend/tests/test_desktop_auth.py
index a7201ac433..913c3cc355 100644
--- a/studio/backend/tests/test_desktop_auth.py
+++ b/studio/backend/tests/test_desktop_auth.py
@@ -437,6 +437,7 @@ def test_health_response_reports_desktop_capability_fields(monkeypatch):
         inference_router = APIRouter(),
         inference_studio_router = APIRouter(),
         models_router = APIRouter(),
+        providers_router = APIRouter(),
         training_history_router = APIRouter(),
         training_router = APIRouter(),
     )
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
new file mode 100644
index 0000000000..22ccba7058
--- /dev/null
+++ b/studio/backend/tests/test_openai_responses_translation.py
@@ -0,0 +1,494 @@
+# 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 OpenAI `/v1/responses` translation in external_provider.
+
+Covers:
+- Request body shape: system messages collapse into `instructions`, user/
+  assistant messages go into `input`, sampling knobs Responses does not
+  support (presence_penalty, top_k) are not forwarded.
+- SSE translation: `response.output_text.delta` events become OpenAI Chat
+  Completions chunks, `response.completed` emits a `finish_reason: stop`
+  chunk, the stream terminates with `data: [DONE]`.
+- Image parts in user content are rewritten from Chat Completions
+  `{type: image_url, image_url: {url}}` into Responses
+  `{type: input_image, image_url: }`.
+"""
+
+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 = "openai",
+        base_url = "https://api.openai.com/v1",
+        api_key = "sk-test",
+    )
+
+
+def _responses_sse(events: list[dict]) -> bytes:
+    """Serialize a list of Responses-API event dicts as an SSE byte stream."""
+    chunks: list[str] = []
+    for event in events:
+        chunks.append(f"event: {event['type']}")
+        chunks.append(f"data: {json.dumps(event)}")
+        chunks.append("")
+    chunks.append("data: [DONE]")
+    chunks.append("")
+    return ("\n".join(chunks) + "\n").encode("utf-8")
+
+
+def test_responses_request_body_uses_input_and_instructions(monkeypatch):
+    captured: dict = {}
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        captured["url"] = str(request.url)
+        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": "system", "content": "You are concise."},
+                {"role": "user", "content": "Hi"},
+            ],
+            model = "gpt-5.5",
+            temperature = 0.5,
+            top_p = 0.9,
+            max_tokens = 512,
+            enable_thinking = None,
+            reasoning_effort = None,
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    assert captured["url"] == "https://api.openai.com/v1/responses"
+    body = captured["body"]
+    assert body["model"] == "gpt-5.5"
+    assert body["instructions"] == "You are concise."
+    assert body["input"] == [{"role": "user", "content": "Hi"}]
+    assert body["max_output_tokens"] == 512
+    assert body["stream"] is True
+    # Responses API on reasoning-class models (gpt-5.x / o3 / gpt-4.5 — the
+    # only OpenAI ids the registry allowlist exposes) rejects these as
+    # `Unsupported parameter`. Make sure we never silently forward them.
+    assert "temperature" not in body
+    assert "top_p" not in body
+    assert "presence_penalty" not in body
+    assert "frequency_penalty" not in body
+    assert "top_k" not in body
+    assert "messages" not in body
+
+
+def test_responses_translates_image_parts(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": [
+                        {"type": "text", "text": "What is this?"},
+                        {
+                            "type": "image_url",
+                            "image_url": {"url": "data:image/png;base64,AAA"},
+                        },
+                    ],
+                }
+            ],
+            model = "gpt-5.5",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = None,
+            enable_thinking = None,
+            reasoning_effort = None,
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+
+    parts = captured["body"]["input"][0]["content"]
+    assert parts[0] == {"type": "input_text", "text": "What is this?"}
+    assert parts[1] == {
+        "type": "input_image",
+        "image_url": "data:image/png;base64,AAA",
+    }
+    # No max_output_tokens key when caller passes max_tokens=None.
+    assert "max_output_tokens" not in captured["body"]
+
+
+def test_responses_sse_translates_to_chat_completions_chunks(monkeypatch):
+    def handler(request: httpx.Request) -> httpx.Response:
+        events = [
+            {"type": "response.created"},
+            {"type": "response.output_text.delta", "delta": "Hello"},
+            {"type": "response.output_text.delta", "delta": ", world"},
+            {"type": "response.completed", "response": {}},
+        ]
+        return httpx.Response(
+            200,
+            content = _responses_sse(events),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        lines = await _collect(
+            client._stream_openai_responses(
+                messages = [{"role": "user", "content": "hi"}],
+                model = "gpt-5.5",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = None,
+                enable_thinking = None,
+                reasoning_effort = None,
+            )
+        )
+        await client.close()
+        return lines
+
+    lines = _drive(run())
+
+    # Drop empty / non-data lines for assertion clarity.
+    data_lines = [line for line in lines if line.startswith("data:")]
+    payloads = []
+    for line in data_lines:
+        raw = line[len("data:") :].strip()
+        if raw == "[DONE]":
+            payloads.append("[DONE]")
+        else:
+            payloads.append(json.loads(raw))
+
+    # Two text deltas, one terminal chunk, then [DONE].
+    assert payloads[0]["choices"][0]["delta"]["content"] == "Hello"
+    assert payloads[0]["choices"][0]["finish_reason"] is None
+    assert payloads[1]["choices"][0]["delta"]["content"] == ", world"
+    assert payloads[2]["choices"][0]["delta"] == {}
+    assert payloads[2]["choices"][0]["finish_reason"] == "stop"
+    assert payloads[-1] == "[DONE]"
+
+
+def test_responses_response_incomplete_maps_to_length_finish_reason(monkeypatch):
+    def handler(request: httpx.Request) -> httpx.Response:
+        events = [
+            {"type": "response.output_text.delta", "delta": "partial"},
+            {"type": "response.incomplete", "response": {}},
+        ]
+        return httpx.Response(
+            200,
+            content = _responses_sse(events),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        lines = await _collect(
+            client._stream_openai_responses(
+                messages = [{"role": "user", "content": "hi"}],
+                model = "gpt-5.5",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = 4,
+                enable_thinking = None,
+                reasoning_effort = None,
+            )
+        )
+        await client.close()
+        return lines
+
+    lines = _drive(run())
+    finish_reasons = [
+        json.loads(line[len("data:") :].strip())["choices"][0]["finish_reason"]
+        for line in lines
+        if line.startswith("data:")
+        and line[len("data:") :].strip() not in ("", "[DONE]")
+    ]
+    assert "length" in finish_reasons
+
+
+def test_responses_reasoning_effort_included_when_requested(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 = "gpt-5.5",
+            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", "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 = {}
+
+    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 = "gpt-5.5",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = None,
+            enable_thinking = None,
+            reasoning_effort = "none",
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+    assert captured["body"]["reasoning"] == {"effort": "none"}
+
+
+def test_responses_reasoning_effort_xhigh_passthrough(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 = "gpt-5.5",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = None,
+            enable_thinking = None,
+            reasoning_effort = "xhigh",
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+    assert captured["body"]["reasoning"] == {"effort": "xhigh", "summary": "auto"}
+
+
+def test_responses_enable_thinking_false_maps_to_reasoning_none(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 = "gpt-5.5",
+            temperature = 0.7,
+            top_p = 0.95,
+            max_tokens = None,
+            enable_thinking = False,
+            reasoning_effort = None,
+        ):
+            pass
+        await client.close()
+
+    _drive(run())
+    assert captured["body"]["reasoning"] == {"effort": "none"}
+
+
+def test_responses_reasoning_summary_wrapped_in_think_tags(monkeypatch):
+    def handler(request: httpx.Request) -> httpx.Response:
+        events = [
+            {
+                "type": "response.output_item.done",
+                "item": {
+                    "type": "reasoning",
+                    "summary": [{"type": "summary_text", "text": "plan"}],
+                },
+            },
+            {"type": "response.output_text.delta", "delta": "answer"},
+            {"type": "response.completed", "response": {}},
+        ]
+        return httpx.Response(
+            200,
+            content = _responses_sse(events),
+            headers = {"content-type": "text/event-stream"},
+        )
+
+    _mock_http_client(monkeypatch, handler)
+
+    async def run():
+        client = _make_client()
+        lines = await _collect(
+            client._stream_openai_responses(
+                messages = [{"role": "user", "content": "hi"}],
+                model = "gpt-5.5",
+                temperature = 0.7,
+                top_p = 0.95,
+                max_tokens = None,
+                enable_thinking = None,
+                reasoning_effort = None,
+            )
+        )
+        await client.close()
+        return lines
+
+    lines = _drive(run())
+    data_lines = [
+        line[len("data:") :].strip()
+        for line in lines
+        if line.startswith("data:")
+        and line[len("data:") :].strip() not in ("", "[DONE]")
+    ]
+    payloads = [json.loads(raw) for raw in data_lines]
+    combined = "".join(
+        payload["choices"][0]["delta"].get("content", "")
+        for payload in payloads
+        if payload["choices"][0]["delta"]
+    )
+    assert "plananswer" in combined
diff --git a/studio/backend/tests/test_providers_api.py b/studio/backend/tests/test_providers_api.py
new file mode 100644
index 0000000000..0e668944f4
--- /dev/null
+++ b/studio/backend/tests/test_providers_api.py
@@ -0,0 +1,609 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""
+Integration tests for the external providers API.
+
+Requires a running Unsloth Studio server. Configure via environment variables:
+
+    export STUDIO_TEST_URL="http://localhost:8888"   # default
+    export STUDIO_TEST_USER="unsloth"                # default
+    export STUDIO_TEST_PASSWORD="..."                # required — see .bootstrap_password
+
+    # Provider API keys — any left unset will have their tests automatically skipped
+    export OPENAI_API_KEY="sk-..."
+    export MISTRAL_API_KEY="..."
+    export GOOGLE_API_KEY="..."
+    export TOGETHER_API_KEY="..."
+    export FIREWORKS_API_KEY="..."
+    export PERPLEXITY_API_KEY="..."
+
+Run:
+    cd studio/backend
+    pytest tests/test_providers_api.py -v -s
+"""
+
+import base64
+import json
+import os
+
+import pytest
+import requests
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import padding
+
+# ── Configuration ─────────────────────────────────────────────────
+
+BASE_URL = os.getenv("STUDIO_TEST_URL", "http://localhost:8000")
+USERNAME = os.getenv("STUDIO_TEST_USER", "unsloth")
+PASSWORD = os.getenv("STUDIO_TEST_PASSWORD", "")
+
+# These tests require a live Studio server reachable at BASE_URL with a known
+# bootstrap password. Skip the whole module when that environment is missing
+# (e.g. on CI runners) so pytest discovery does not error out.
+pytestmark = pytest.mark.skipif(
+    not PASSWORD,
+    reason = "Integration test requires a running Studio server; set STUDIO_TEST_PASSWORD to enable.",
+)
+
+# Map provider_type → (env var name, model to use for inference test)
+_PROVIDER_CONFIGS: dict[str, tuple[str, str]] = {
+    "openai": ("OPENAI_API_KEY", "gpt-4o-mini"),
+    "mistral": ("MISTRAL_API_KEY", "mistral-small-2506"),
+    "gemini": ("GEMINI_API_KEY", "gemini-3-flash-preview"),
+    "openrouter": ("OPENROUTER_API_KEY", "openai/gpt-4o-mini"),
+    "anthropic": ("ANTHROPIC_API_KEY", "claude-haiku-4-5"),
+    "deepseek": ("DEEPSEEK_API_KEY", "deepseek-chat"),
+    "huggingface": ("HUGGINGFACE_API_KEY", "meta-llama/Llama-3.3-70B-Instruct"),
+    "kimi": ("MOONSHOT_API_KEY", "moonshot-v1-8k"),
+    "qwen": ("DASHSCOPE_API_KEY", "qwen-turbo"),
+}
+
+PROVIDER_KEYS: dict[str, str] = {
+    ptype: os.getenv(env_var, "") for ptype, (env_var, _) in _PROVIDER_CONFIGS.items()
+}
+
+EXPECTED_PROVIDER_TYPES = set(_PROVIDER_CONFIGS.keys())
+
+# ── Helpers ────────────────────────────────────────────────────────
+
+
+def _url(path: str) -> str:
+    return f"{BASE_URL}/{path.lstrip('/')}"
+
+
+def _parse_sse_stream(response: requests.Response) -> tuple[str, bool]:
+    """
+    Read a streaming SSE response and return (assembled_text, saw_done).
+
+    Each chunk is a JSON object with choices[0].delta.content.
+    The stream ends with `data: [DONE]`.
+    """
+    reply_parts: list[str] = []
+    saw_done = False
+
+    for raw_line in response.iter_lines():
+        if isinstance(raw_line, bytes):
+            raw_line = raw_line.decode("utf-8")
+        if not raw_line.startswith("data:"):
+            continue
+        data = raw_line[len("data:") :].strip()
+        if data == "[DONE]":
+            saw_done = True
+            break
+        try:
+            chunk = json.loads(data)
+            # Handle both error payloads and normal chunks
+            if "error" in chunk:
+                raise RuntimeError(f"Provider error in stream: {chunk['error']}")
+            delta = chunk.get("choices", [{}])[0].get("delta", {})
+            content = delta.get("content") or ""
+            if content:
+                reply_parts.append(content)
+        except (json.JSONDecodeError, IndexError, KeyError):
+            pass  # skip malformed lines
+
+    return "".join(reply_parts), saw_done
+
+
+# ── Session-scoped fixtures ────────────────────────────────────────
+
+
+@pytest.fixture(scope = "session")
+def auth_headers() -> dict[str, str]:
+    """
+    Log in once per session and return auth headers.
+
+    On a fresh Studio install the bootstrap password triggers a forced password
+    change (must_change_password=True).  Any subsequent API call using that token
+    returns 403 "Password change required".  This fixture detects that state,
+    automatically completes the change-password flow, and re-logs in so all other
+    tests get a fully usable token.
+
+    The new password used during auto-change is:
+        STUDIO_TEST_NEW_PASSWORD  (env var, optional)
+        or PASSWORD + "-test"     (derived default)
+
+    On the second run, set STUDIO_TEST_PASSWORD to the new password.
+    """
+    assert PASSWORD, (
+        "STUDIO_TEST_PASSWORD is not set.\n"
+        "Run: export STUDIO_TEST_PASSWORD=$(cat studio/backend/.bootstrap_password)"
+    )
+
+    resp = requests.post(
+        _url("/api/auth/login"),
+        json = {"username": USERNAME, "password": PASSWORD},
+        timeout = 10,
+    )
+    assert resp.status_code == 200, f"Login failed ({resp.status_code}): {resp.text}"
+    body = resp.json()
+    token = body["access_token"]
+    assert token, "access_token is empty"
+
+    if body.get("must_change_password"):
+        # Bootstrap token is restricted — only /api/auth/change-password works with it.
+        # Auto-complete the forced change so the rest of the tests get a full token.
+        new_password = os.getenv("STUDIO_TEST_NEW_PASSWORD") or f"{PASSWORD}-test"
+        change_resp = requests.post(
+            _url("/api/auth/change-password"),
+            headers = {"Authorization": f"Bearer {token}"},
+            json = {"current_password": PASSWORD, "new_password": new_password},
+            timeout = 10,
+        )
+        assert (
+            change_resp.status_code == 200
+        ), f"Auto password-change failed ({change_resp.status_code}): {change_resp.text}"
+        token = change_resp.json()["access_token"]
+
+    return {"Authorization": f"Bearer {token}"}
+
+
+@pytest.fixture(scope = "session")
+def public_key_pem(auth_headers: dict[str, str]) -> str:
+    """Fetch RSA public key PEM once per session."""
+    resp = requests.get(
+        _url("/api/providers/public-key"),
+        headers = auth_headers,
+        timeout = 10,
+    )
+    assert resp.status_code == 200, f"Public key fetch failed: {resp.text}"
+    pem = resp.json().get("public_key", "")
+    assert pem.startswith("-----BEGIN PUBLIC KEY-----"), "Not a valid PEM public key"
+    return pem
+
+
+@pytest.fixture(scope = "session")
+def vision_image_data_url() -> str:
+    """
+    Download the sloth image once per session and return it as a base64 data URI.
+
+    Using a data URI instead of a remote URL ensures every provider receives
+    the image inline — Gemini's OpenAI-compatible layer does not fetch external
+    HTTP URLs, so raw image_url links silently produce empty replies for Gemini.
+    """
+    resp = requests.get(_VISION_IMAGE_URL, timeout = 30)
+    resp.raise_for_status()
+    content_type = resp.headers.get("Content-Type", "image/jpeg").split(";")[0].strip()
+    b64 = base64.b64encode(resp.content).decode("utf-8")
+    return f"data:{content_type};base64,{b64}"
+
+
+@pytest.fixture(scope = "session")
+def encrypt_key(public_key_pem: str):
+    """
+    Return a callable encrypt_key(plaintext: str) -> str (base64 RSA-OAEP ciphertext).
+    Uses the backend's RSA public key — mirrors what the frontend does.
+    """
+    # Decode PEM → load RSA public key
+    pem_bytes = public_key_pem.encode("utf-8")
+    rsa_pub = serialization.load_pem_public_key(pem_bytes)
+
+    def _encrypt(plaintext: str) -> str:
+        ciphertext = rsa_pub.encrypt(
+            plaintext.encode("utf-8"),
+            padding.OAEP(
+                mgf = padding.MGF1(algorithm = hashes.SHA256()),
+                algorithm = hashes.SHA256(),
+                label = None,
+            ),
+        )
+        return base64.b64encode(ciphertext).decode("utf-8")
+
+    return _encrypt
+
+
+# ── TestAuth ────────────────────────────────────────────────────────
+
+
+class TestAuth:
+    def test_login_returns_token(self):
+        """POST /api/auth/login returns a non-empty access_token."""
+        assert PASSWORD, "STUDIO_TEST_PASSWORD not set"
+        resp = requests.post(
+            _url("/api/auth/login"),
+            json = {"username": USERNAME, "password": PASSWORD},
+            timeout = 10,
+        )
+        assert (
+            resp.status_code == 200
+        ), f"Login failed ({resp.status_code}): {resp.text}"
+        body = resp.json()
+        assert body.get("access_token"), "access_token is missing or empty"
+        assert body.get("token_type") == "bearer"
+
+
+# ── TestPublicKey ────────────────────────────────────────────────────
+
+
+class TestPublicKey:
+    def test_public_key_is_valid_pem(
+        self, auth_headers: dict[str, str], public_key_pem: str
+    ):
+        """GET /api/providers/public-key returns an importable RSA PEM key."""
+        pem_bytes = public_key_pem.encode("utf-8")
+        key = serialization.load_pem_public_key(pem_bytes)
+        key_size = key.key_size  # type: ignore[attr-defined]
+        assert key_size >= 2048, f"Key size too small: {key_size}"
+        print(f"\n  RSA-{key_size} public key OK")
+
+
+# ── TestRegistry ────────────────────────────────────────────────────
+
+
+class TestRegistry:
+    def test_registry_returns_all_providers(self, auth_headers: dict[str, str]):
+        """GET /api/providers/registry returns all supported providers."""
+        resp = requests.get(
+            _url("/api/providers/registry"),
+            headers = auth_headers,
+            timeout = 10,
+        )
+        assert resp.status_code == 200, f"Registry failed: {resp.text}"
+        providers = resp.json()
+        assert (
+            len(providers) == 9
+        ), f"Expected 9 providers, got {len(providers)}: {providers}"
+        print(f"\n  {'Provider':<12} {'Base URL'}")
+        print(f"  {'-'*12} {'-'*45}")
+        for p in providers:
+            print(f"  {p['provider_type']:<12} {p['base_url']}")
+
+    def test_registry_has_expected_types(self, auth_headers: dict[str, str]):
+        """All expected provider_type values are present in the registry."""
+        resp = requests.get(
+            _url("/api/providers/registry"),
+            headers = auth_headers,
+            timeout = 10,
+        )
+        assert resp.status_code == 200
+        returned_types = {p["provider_type"] for p in resp.json()}
+        missing = EXPECTED_PROVIDER_TYPES - returned_types
+        assert not missing, f"Missing provider types: {missing}"
+
+    def test_registry_entries_have_required_fields(self, auth_headers: dict[str, str]):
+        """Each registry entry has provider_type, display_name, base_url, default_models."""
+        resp = requests.get(
+            _url("/api/providers/registry"), headers = auth_headers, timeout = 10
+        )
+        assert resp.status_code == 200
+        for entry in resp.json():
+            for field in (
+                "provider_type",
+                "display_name",
+                "base_url",
+                "default_models",
+                "model_list_mode",
+            ):
+                assert field in entry, f"Missing field '{field}' in entry: {entry}"
+            assert entry["model_list_mode"] in ("remote", "curated")
+            assert isinstance(entry["default_models"], list)
+            assert len(entry["default_models"]) > 0
+
+
+# ── TestProviderCRUD ────────────────────────────────────────────────
+
+
+class TestProviderCRUD:
+    """
+    These tests run sequentially within the class and share state via class variables.
+    They create, read, update, and delete a single test provider config.
+    """
+
+    _created_id: str = ""
+
+    def test_create_provider(self, auth_headers: dict[str, str]):
+        """POST /api/providers/ creates a provider config and returns 201."""
+        resp = requests.post(
+            _url("/api/providers/"),
+            headers = auth_headers,
+            json = {"provider_type": "openai", "display_name": "Test OpenAI (pytest)"},
+            timeout = 10,
+        )
+        assert (
+            resp.status_code == 201
+        ), f"Create failed ({resp.status_code}): {resp.text}"
+        body = resp.json()
+        assert body.get("id"), "No id in response"
+        assert body["provider_type"] == "openai"
+        assert body["display_name"] == "Test OpenAI (pytest)"
+        assert body["is_enabled"] is True
+        TestProviderCRUD._created_id = body["id"]
+        print(f"\n  created id={body['id']}")
+
+    def test_list_includes_created(self, auth_headers: dict[str, str]):
+        """GET /api/providers/ includes the newly created config."""
+        assert (
+            TestProviderCRUD._created_id
+        ), "No created_id (run test_create_provider first)"
+        resp = requests.get(_url("/api/providers/"), headers = auth_headers, timeout = 10)
+        assert resp.status_code == 200
+        ids = [p["id"] for p in resp.json()]
+        assert (
+            TestProviderCRUD._created_id in ids
+        ), f"Created id {TestProviderCRUD._created_id!r} not found in list: {ids}"
+        print(f"\n  found id={TestProviderCRUD._created_id} in list of {len(ids)}")
+
+    def test_update_display_name(self, auth_headers: dict[str, str]):
+        """PUT /api/providers/{id} updates the display_name."""
+        assert TestProviderCRUD._created_id, "No created_id"
+        new_name = "Test OpenAI (pytest updated)"
+        resp = requests.put(
+            _url(f"/api/providers/{TestProviderCRUD._created_id}"),
+            headers = auth_headers,
+            json = {"display_name": new_name},
+            timeout = 10,
+        )
+        assert (
+            resp.status_code == 200
+        ), f"Update failed ({resp.status_code}): {resp.text}"
+        assert resp.json()["display_name"] == new_name
+        print(f"\n  updated display_name to '{new_name}'")
+
+    def test_delete_provider(self, auth_headers: dict[str, str]):
+        """DELETE /api/providers/{id} removes the config (204) and it's gone from list."""
+        assert TestProviderCRUD._created_id, "No created_id"
+        resp = requests.delete(
+            _url(f"/api/providers/{TestProviderCRUD._created_id}"),
+            headers = auth_headers,
+            timeout = 10,
+        )
+        assert (
+            resp.status_code == 204
+        ), f"Delete failed ({resp.status_code}): {resp.text}"
+
+        # Confirm gone from list
+        list_resp = requests.get(
+            _url("/api/providers/"), headers = auth_headers, timeout = 10
+        )
+        ids = [p["id"] for p in list_resp.json()]
+        assert TestProviderCRUD._created_id not in ids, "Deleted provider still in list"
+        print(f"\n  deleted id={TestProviderCRUD._created_id} confirmed gone")
+
+
+# ── TestProviderInference ────────────────────────────────────────────
+
+
+# Build parametrize list: (provider_type, model, api_key) for configured providers only
+_INFERENCE_PARAMS = [
+    pytest.param(
+        ptype,
+        model,
+        PROVIDER_KEYS.get(ptype, ""),
+        id = ptype,
+        marks = pytest.mark.skipif(
+            not PROVIDER_KEYS.get(ptype, ""),
+            reason = f"no {env_var} set",
+        ),
+    )
+    for ptype, (env_var, model) in _PROVIDER_CONFIGS.items()
+]
+
+
+class TestProviderInference:
+    """
+    Live inference tests — one parametrized set per provider.
+    Each test is automatically skipped when the provider's API key env var is not set.
+    """
+
+    @pytest.mark.parametrize("provider_type,model,api_key", _INFERENCE_PARAMS)
+    def test_connection(
+        self,
+        auth_headers: dict[str, str],
+        encrypt_key,
+        provider_type: str,
+        model: str,
+        api_key: str,
+    ):
+        """POST /api/providers/test → success: true."""
+        encrypted = encrypt_key(api_key)
+        resp = requests.post(
+            _url("/api/providers/test"),
+            headers = auth_headers,
+            json = {"provider_type": provider_type, "encrypted_api_key": encrypted},
+            timeout = 30,
+        )
+        assert (
+            resp.status_code == 200
+        ), f"Request failed ({resp.status_code}): {resp.text}"
+        body = resp.json()
+        assert (
+            body["success"] is True
+        ), f"Connection test failed for {provider_type}: {body.get('message')}"
+        print(f"\n  [{provider_type}] connection OK — {body['message']}")
+
+    @pytest.mark.parametrize("provider_type,model,api_key", _INFERENCE_PARAMS)
+    def test_list_models(
+        self,
+        auth_headers: dict[str, str],
+        encrypt_key,
+        provider_type: str,
+        model: str,
+        api_key: str,
+    ):
+        """POST /api/providers/models → non-empty list, print first 3."""
+        encrypted = encrypt_key(api_key)
+        resp = requests.post(
+            _url("/api/providers/models"),
+            headers = auth_headers,
+            json = {"provider_type": provider_type, "encrypted_api_key": encrypted},
+            timeout = 30,
+        )
+        assert (
+            resp.status_code == 200
+        ), f"Request failed ({resp.status_code}): {resp.text}"
+        models = resp.json()
+        assert isinstance(models, list), f"Expected list, got {type(models)}"
+        assert len(models) > 0, f"No models returned for {provider_type}"
+        preview = [m["id"] for m in models[:3]]
+        print(f"\n  [{provider_type}] {len(models)} models — first 3: {preview}")
+
+    @pytest.mark.parametrize("provider_type,model,api_key", _INFERENCE_PARAMS)
+    def test_chat_inference(
+        self,
+        auth_headers: dict[str, str],
+        encrypt_key,
+        provider_type: str,
+        model: str,
+        api_key: str,
+    ):
+        """POST /v1/chat/completions with provider fields → streamed reply."""
+        encrypted = encrypt_key(api_key)
+        payload = {
+            "messages": [{"role": "user", "content": "Say hello in one sentence."}],
+            "stream": True,
+            "temperature": 0.7,
+            "max_tokens": 64,
+            "provider_type": provider_type,
+            "external_model": model,
+            "encrypted_api_key": encrypted,
+        }
+        with requests.post(
+            _url("/v1/chat/completions"),
+            headers = {**auth_headers, "Content-Type": "application/json"},
+            json = payload,
+            stream = True,
+            timeout = 60,
+        ) as resp:
+            assert (
+                resp.status_code == 200
+            ), f"Chat completions failed ({resp.status_code}): {resp.text[:500]}"
+            reply, saw_done = _parse_sse_stream(resp)
+
+        assert reply.strip(), f"Empty reply from {provider_type}/{model}"
+        assert saw_done, f"Stream did not end with [DONE] for {provider_type}/{model}"
+        print(f'\n  [{provider_type}/{model}] reply: "{reply.strip()}"')
+
+
+# ── TestVisionInference ─────────────────────────────────────────────
+
+# Sloth photo — used to test vision routing across providers
+_VISION_IMAGE_URL = (
+    "https://www.travelexcellence.com/images/where-to-see-sloths-in-costa-rica.jpg"
+)
+
+_VISION_PARAMS = [
+    pytest.param(
+        ptype,
+        model,
+        PROVIDER_KEYS.get(ptype, ""),
+        id = ptype,
+        marks = pytest.mark.skipif(
+            not PROVIDER_KEYS.get(ptype, ""),
+            reason = f"no key for {ptype}",
+        ),
+    )
+    for ptype, (_, model) in _PROVIDER_CONFIGS.items()
+    if ptype in {"openai", "mistral", "gemini", "anthropic", "openrouter"}
+]
+
+
+class TestVisionInference:
+    """
+    Send a 1×1 white PNG alongside a text question to each vision-capable provider.
+    Verifies that image content parts survive the proxy and the provider replies.
+    """
+
+    @pytest.mark.parametrize("provider_type,model,api_key", _VISION_PARAMS)
+    def test_vision_chat_inference(
+        self,
+        auth_headers: dict[str, str],
+        encrypt_key,
+        vision_image_data_url: str,
+        provider_type: str,
+        model: str,
+        api_key: str,
+    ):
+        """Image URL + text message → non-empty streamed reply."""
+        encrypted = encrypt_key(api_key)
+        payload = {
+            "messages": [
+                {
+                    "role": "user",
+                    "content": [
+                        {
+                            "type": "text",
+                            "text": "Which animal is in this image? Reply in one word.",
+                        },
+                        {
+                            "type": "image_url",
+                            "image_url": {"url": vision_image_data_url},
+                        },
+                    ],
+                }
+            ],
+            "stream": True,
+            "max_tokens": 215,
+            "provider_type": provider_type,
+            "external_model": model,
+            "encrypted_api_key": encrypted,
+        }
+        with requests.post(
+            _url("/v1/chat/completions"),
+            headers = {**auth_headers, "Content-Type": "application/json"},
+            json = payload,
+            stream = True,
+            timeout = 60,
+        ) as resp:
+            assert (
+                resp.status_code == 200
+            ), f"Vision request failed ({resp.status_code}): {resp.text[:300]}"
+            reply, saw_done = _parse_sse_stream(resp)
+
+        assert reply.strip(), f"Empty reply from {provider_type}/{model}"
+        assert saw_done, f"Stream did not end with [DONE] for {provider_type}/{model}"
+        print(f"\n  [{provider_type}/{model}] vision reply: {reply.strip()!r}")
+
+
+# ── TestLocalInferenceUnaffected ────────────────────────────────────
+
+
+class TestLocalInferenceUnaffected:
+    def test_chat_without_provider(self, auth_headers: dict[str, str]):
+        """
+        POST /v1/chat/completions without provider fields must not return 422 or 500.
+
+        200 = a local model is loaded and responded.
+        503 = no model loaded (expected in test environment — that's fine).
+        Any other 4xx/5xx (except 503) = regression in request handling.
+        """
+        resp = requests.post(
+            _url("/v1/chat/completions"),
+            headers = {**auth_headers, "Content-Type": "application/json"},
+            json = {
+                "messages": [{"role": "user", "content": "Hello"}],
+                "stream": False,
+            },
+            timeout = 15,
+        )
+        allowed = {200, 400, 503}
+        assert resp.status_code in allowed, (
+            f"Unexpected status {resp.status_code} for local inference path: {resp.text[:300]}\n"
+            f"This likely means the provider fields broke the base request schema."
+        )
+        status_label = (
+            "local model responded"
+            if resp.status_code == 200
+            else "no model loaded (expected)"
+        )
+        print(f"\n  status={resp.status_code} ({status_label}) — local path unaffected")
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 9eacf893ad..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
 
@@ -1696,20 +1816,21 @@ def get_base_model_from_checkpoint(checkpoint_path: str) -> Optional[str]:
                         )
                         return base_model
 
-        training_args_path = checkpoint_path_obj / "training_args.bin"
-        if training_args_path.exists():
-            try:
-                import torch
-
-                training_args = torch.load(training_args_path)
-                if hasattr(training_args, "model_name_or_path"):
-                    base_model = training_args.model_name_or_path
-                    logger.info(
-                        "Detected base model from training_args.bin: %s", base_model
-                    )
-                    return base_model
-            except Exception as e:
-                logger.warning(f"Could not load training_args.bin: {e}")
+        # TODO: torch.load default weights_only=True (torch >= 2.6) rejects pickled TrainingArguments; re-enable via safe_globals or weights_only=False once threat model allows.
+        # training_args_path = checkpoint_path_obj / "training_args.bin"
+        # if training_args_path.exists():
+        #     try:
+        #         import torch
+        #
+        #         training_args = torch.load(training_args_path)
+        #         if hasattr(training_args, "model_name_or_path"):
+        #             base_model = training_args.model_name_or_path
+        #             logger.info(
+        #                 "Detected base model from training_args.bin: %s", base_model
+        #             )
+        #             return base_model
+        #     except Exception as e:
+        #         logger.warning(f"Could not load training_args.bin: {e}")
 
         dir_name = checkpoint_path_obj.name
         if dir_name.startswith("unsloth_"):
@@ -1757,20 +1878,21 @@ def get_base_model_from_lora(lora_path: str) -> Optional[str]:
                     return base_model
 
         # Fallback: try training_args.bin (requires torch)
-        training_args_path = lora_path_obj / "training_args.bin"
-        if training_args_path.exists():
-            try:
-                import torch
-
-                training_args = torch.load(training_args_path)
-                if hasattr(training_args, "model_name_or_path"):
-                    base_model = training_args.model_name_or_path
-                    logger.info(
-                        f"Detected base model from training_args.bin: {base_model}"
-                    )
-                    return base_model
-            except Exception as e:
-                logger.warning(f"Could not load training_args.bin: {e}")
+        # TODO: torch.load default weights_only=True (torch >= 2.6) rejects pickled TrainingArguments; also an RCE sink for third-party LoRAs via this route, re-enable behind a trust check if needed.
+        # training_args_path = lora_path_obj / "training_args.bin"
+        # if training_args_path.exists():
+        #     try:
+        #         import torch
+        #
+        #         training_args = torch.load(training_args_path)
+        #         if hasattr(training_args, "model_name_or_path"):
+        #             base_model = training_args.model_name_or_path
+        #             logger.info(
+        #                 f"Detected base model from training_args.bin: {base_model}"
+        #             )
+        #             return base_model
+        #     except Exception as e:
+        #         logger.warning(f"Could not load training_args.bin: {e}")
 
         # Last resort: parse from directory name
         # Format: unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit_timestamp
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 21d31d81e6..ee0d8a7832 100644
--- a/studio/frontend/package-lock.json
+++ b/studio/frontend/package-lock.json
@@ -33,7 +33,7 @@
         "@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",
@@ -56,8 +56,8 @@
         "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",
         "react": "^19.2.4",
         "react-day-picker": "^9.13.2",
@@ -80,6 +80,7 @@
         "@eslint/js": "^9.39.1",
         "@types/js-yaml": "^4.0.9",
         "@types/node": "^25.5.2",
+        "@types/node-forge": "^1.3.14",
         "@types/react": "^19.2.5",
         "@types/react-dom": "^19.2.3",
         "@vitejs/plugin-react": "^6.0.1",
@@ -1540,472 +1541,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",
@@ -2266,140 +1801,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",
@@ -6450,15 +5851,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",
@@ -7377,6 +6769,16 @@
         "undici-types": "~7.19.0"
       }
     },
+    "node_modules/@types/node-forge": {
+      "version": "1.3.14",
+      "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
+      "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/react": {
       "version": "19.2.14",
       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -8455,12 +7857,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",
@@ -13184,59 +12580,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",
@@ -13285,6 +12628,15 @@
         "url": "https://opencollective.com/node-fetch"
       }
     },
+    "node_modules/node-forge": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
+      "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
+      "license": "(BSD-3-Clause OR GPL-2.0)",
+      "engines": {
+        "node": ">= 6.13.0"
+      }
+    },
     "node_modules/node-releases": {
       "version": "2.0.38",
       "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
@@ -13817,34 +13169,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",
@@ -13858,24 +13182,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",
@@ -15142,64 +14448,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",
@@ -15580,29 +14828,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 3a02bb926a..e8ede65526 100644
--- a/studio/frontend/package.json
+++ b/studio/frontend/package.json
@@ -64,8 +64,8 @@
     "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",
     "react": "^19.2.4",
     "react-day-picker": "^9.13.2",
@@ -92,6 +92,7 @@
     "@biomejs/biome": "^1.9.4",
     "@eslint/js": "^9.39.1",
     "@types/js-yaml": "^4.0.9",
+    "@types/node-forge": "^1.3.14",
     "@types/node": "^25.5.2",
     "@types/react": "^19.2.5",
     "@types/react-dom": "^19.2.3",
diff --git a/studio/frontend/public/provider-logos/anthropic.svg b/studio/frontend/public/provider-logos/anthropic.svg
new file mode 100644
index 0000000000..7545cc8f3e
--- /dev/null
+++ b/studio/frontend/public/provider-logos/anthropic.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/studio/frontend/public/provider-logos/deepseek.svg b/studio/frontend/public/provider-logos/deepseek.svg
new file mode 100644
index 0000000000..d1ba06b942
--- /dev/null
+++ b/studio/frontend/public/provider-logos/deepseek.svg
@@ -0,0 +1,14 @@
+
+
+  
+    
+  
+  
+    
+  
+
\ No newline at end of file
diff --git a/studio/frontend/public/provider-logos/gemini.svg b/studio/frontend/public/provider-logos/gemini.svg
new file mode 100644
index 0000000000..9090dfb68e
--- /dev/null
+++ b/studio/frontend/public/provider-logos/gemini.svg
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/studio/frontend/public/provider-logos/huggingface.svg b/studio/frontend/public/provider-logos/huggingface.svg
new file mode 100644
index 0000000000..ab959d165f
--- /dev/null
+++ b/studio/frontend/public/provider-logos/huggingface.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/studio/frontend/public/provider-logos/kimi.jpg b/studio/frontend/public/provider-logos/kimi.jpg
new file mode 100644
index 0000000000..956a5b58b1
Binary files /dev/null and b/studio/frontend/public/provider-logos/kimi.jpg differ
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/misc/meta.svg b/studio/frontend/public/provider-logos/misc/meta.svg
new file mode 100644
index 0000000000..9fa656bd6b
--- /dev/null
+++ b/studio/frontend/public/provider-logos/misc/meta.svg
@@ -0,0 +1,19 @@
+
+
+Logo of Meta Platforms -- Graphic created by Detmar Owen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/studio/frontend/public/provider-logos/misc/microsoft.svg b/studio/frontend/public/provider-logos/misc/microsoft.svg
new file mode 100644
index 0000000000..5334aa7ca6
--- /dev/null
+++ b/studio/frontend/public/provider-logos/misc/microsoft.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/studio/frontend/public/provider-logos/misc/minimax.png b/studio/frontend/public/provider-logos/misc/minimax.png
new file mode 100644
index 0000000000..e9472c676d
Binary files /dev/null and b/studio/frontend/public/provider-logos/misc/minimax.png differ
diff --git a/studio/frontend/public/provider-logos/misc/nvidia.svg b/studio/frontend/public/provider-logos/misc/nvidia.svg
new file mode 100644
index 0000000000..ae65b09a2b
--- /dev/null
+++ b/studio/frontend/public/provider-logos/misc/nvidia.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/studio/frontend/public/provider-logos/misc/perplexity.png b/studio/frontend/public/provider-logos/misc/perplexity.png
new file mode 100644
index 0000000000..9845765c7f
Binary files /dev/null and b/studio/frontend/public/provider-logos/misc/perplexity.png differ
diff --git a/studio/frontend/public/provider-logos/misc/xai.svg b/studio/frontend/public/provider-logos/misc/xai.svg
new file mode 100644
index 0000000000..0c83eb3d9b
--- /dev/null
+++ b/studio/frontend/public/provider-logos/misc/xai.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/studio/frontend/public/provider-logos/misc/z-ai.svg b/studio/frontend/public/provider-logos/misc/z-ai.svg
new file mode 100644
index 0000000000..28ca7280a1
--- /dev/null
+++ b/studio/frontend/public/provider-logos/misc/z-ai.svg
@@ -0,0 +1,215 @@
+
+
+
+
+
+	
+	
+		
+			
+				
+					
+					
+					
+				
+			
+		
+	
+
+
\ No newline at end of file
diff --git a/studio/frontend/public/provider-logos/mistral.svg b/studio/frontend/public/provider-logos/mistral.svg
new file mode 100644
index 0000000000..40c2591b31
--- /dev/null
+++ b/studio/frontend/public/provider-logos/mistral.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/openai.svg b/studio/frontend/public/provider-logos/openai.svg
new file mode 100644
index 0000000000..74d9b1b44b
--- /dev/null
+++ b/studio/frontend/public/provider-logos/openai.svg
@@ -0,0 +1,5 @@
+
+
+  
+  
+
\ No newline at end of file
diff --git a/studio/frontend/public/provider-logos/openrouter.svg b/studio/frontend/public/provider-logos/openrouter.svg
new file mode 100644
index 0000000000..4a4968b639
--- /dev/null
+++ b/studio/frontend/public/provider-logos/openrouter.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/studio/frontend/public/provider-logos/qwen.png b/studio/frontend/public/provider-logos/qwen.png
new file mode 100644
index 0000000000..67d2258f40
Binary files /dev/null and b/studio/frontend/public/provider-logos/qwen.png differ
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 22bd7412ab..dc8bffb2b7 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector.tsx
+++ b/studio/frontend/src/components/assistant-ui/model-selector.tsx
@@ -10,24 +10,90 @@ 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,
 } from "@hugeicons/core-free-icons";
 import { HugeiconsIcon } from "@hugeicons/react";
 import { useMemo, useState } from "react";
 import type {
   DeletedModelRef,
+  ExternalModelOption,
   LoraModelOption,
   ModelOption,
   ModelSelectorChangeMeta,
 } from "./model-selector/types";
 import { HubModelPicker, LoraModelPicker } from "./model-selector/pickers";
+import { Input } from "../ui/input";
+
+const PROVIDER_LOGO_EXT: Record = {
+  openai: "svg",
+  mistral: "svg",
+  gemini: "svg",
+  anthropic: "svg",
+  deepseek: "svg",
+  huggingface: "svg",
+  kimi: "jpg",
+  qwen: "png",
+  openrouter: "svg",
+  vllm: "svg",
+  ollama: "svg",
+  llama_cpp: "svg",
+};
+
+function providerLogoSrc(providerType: string | undefined): string | undefined {
+  if (!providerType) return undefined;
+  const ext = PROVIDER_LOGO_EXT[providerType];
+  if (!ext) return undefined;
+  return `${import.meta.env.BASE_URL}provider-logos/${providerType}.${ext}`;
+}
+
+function ExternalProviderLogo({
+  providerType,
+  className,
+  title,
+}: {
+  providerType: string | undefined;
+  className?: string;
+  title?: string;
+}) {
+  const src = providerLogoSrc(providerType);
+  if (!src && isCustomProviderType(providerType)) {
+    return (
+      
+        
+      
+    );
+  }
+
+  if (!src) return null;
+  return (
+    
+  );
+}
 
 export type {
   DeletedModelRef,
+  ExternalModelOption,
   LoraModelOption,
   ModelOption,
   ModelSelectorChangeMeta,
@@ -36,6 +102,7 @@ export type {
 interface ModelSelectorProps {
   models: ModelOption[];
   loraModels?: LoraModelOption[];
+  externalModels?: ExternalModelOption[];
   value?: string;
   defaultValue?: string;
   activeGgufVariant?: string | null;
@@ -53,11 +120,13 @@ interface ModelSelectorProps {
   onOpenChange?: (open: boolean) => void;
   triggerDataTour?: string;
   contentDataTour?: string;
+  showCloudIndicator?: boolean;
 }
 
 function ModelSelectorTrigger({
   currentModel,
   isLoaded,
+  showCloudIndicator = false,
   variant = "outline",
   size = "default",
   className,
@@ -65,6 +134,7 @@ function ModelSelectorTrigger({
 }: {
   currentModel?: ModelOption;
   isLoaded: boolean;
+  showCloudIndicator?: boolean;
   variant?: "outline" | "ghost" | "muted";
   size?: "sm" | "default" | "lg";
   className?: string;
@@ -90,12 +160,27 @@ function ModelSelectorTrigger({
         {isLoaded && (
           
         )}
-        
-          
+        {currentModel?.icon ? (
+          {currentModel.icon}
+        ) : null}
+        
+          
             {currentModel?.name ?? "Select model"}
+            {showCloudIndicator ? (
+              
+            ) : null}
           
           {currentModel?.description && (
-            
+            
               {currentModel.description}
             
           )}
@@ -115,6 +200,7 @@ function ModelSelectorTrigger({
 function ModelSelectorContent({
   models,
   loraModels,
+  externalModels,
   value,
   onSelect,
   onEject,
@@ -127,6 +213,7 @@ function ModelSelectorContent({
 }: {
   models: ModelOption[];
   loraModels: LoraModelOption[];
+  externalModels: ExternalModelOption[];
   value?: string;
   onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
   onEject?: () => void;
@@ -139,6 +226,20 @@ function ModelSelectorContent({
 }) {
   const hasSelection = Boolean(value);
   const chatOnly = usePlatformStore((s) => s.isChatOnly());
+  const hasExternal = externalModels.length > 0;
+  const chatOnlyTabsDefault = useMemo(
+    () => (value && externalModels.some((model) => model.id === value) ? "external" : "hub"),
+    [externalModels, value],
+  );
+  const studioTabsDefault = useMemo((): "hub" | "lora" | "external" => {
+    if (value && externalModels.some((model) => model.id === value)) {
+      return "external";
+    }
+    if (value && loraModels.some((model) => model.id === value)) {
+      return "lora";
+    }
+    return "hub";
+  }, [externalModels, loraModels, value]);
 
   return (
     
       {chatOnly ? (
-        
+        hasExternal ? (
+          
+            
+              Hub models
+              External
+            
+            
+              
+            
+            
+              
+            
+          
+        ) : (
+          
+        )
       ) : (
-        
+        
           
             Hub models
             Fine-tuned
+            {hasExternal ? External : null}
           
 
           
@@ -171,6 +292,16 @@ function ModelSelectorContent({
               deleteDisabled={deleteDisabled}
             />
           
+
+          {hasExternal ? (
+            
+              
+            
+          ) : null}
         
       )}
 
@@ -207,6 +338,7 @@ function ModelSelectorContent({
 export function ModelSelector({
   models,
   loraModels = [],
+  externalModels = [],
   value,
   defaultValue,
   activeGgufVariant,
@@ -224,6 +356,7 @@ export function ModelSelector({
   onOpenChange,
   triggerDataTour,
   contentDataTour,
+  showCloudIndicator = false,
 }: ModelSelectorProps) {
   const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
   const open = controlledOpen ?? uncontrolledOpen;
@@ -266,8 +399,21 @@ export function ModelSelector({
         description: tag,
       });
     }
+    for (const externalModel of externalModels) {
+      all.set(externalModel.id, {
+        ...externalModel,
+        description: externalModel.providerName,
+        icon: (
+          
+        ),
+      });
+    }
     return all;
-  }, [loraModels, models]);
+  }, [externalModels, loraModels, models]);
 
   const currentModel = useMemo(() => {
     if (!selected) return undefined;
@@ -303,6 +449,7 @@ export function ModelSelector({
        void;
+}) {
+  const [query, setQuery] = useState("");
+  const grouped = useMemo(() => {
+    const needle = normalizeForSearch(query.trim());
+    const byProvider = new Map<
+      string,
+      { providerName: string; models: ExternalModelOption[] }
+    >();
+    for (const model of externalModels) {
+      const searchText = normalizeForSearch(
+        `${model.name} ${model.providerName} ${model.id}`,
+      );
+      if (needle && !searchText.includes(needle)) continue;
+      const prev = byProvider.get(model.providerId);
+      if (prev) {
+        prev.models.push(model);
+      } else {
+        byProvider.set(model.providerId, {
+          providerName: model.providerName,
+          models: [model],
+        });
+      }
+    }
+    return [...byProvider.entries()]
+      .map(([providerId, group]) => ({
+        providerId,
+        providerName: group.providerName,
+        models: group.models.sort((a, b) => a.name.localeCompare(b.name)),
+      }))
+      .sort((a, b) => a.providerName.localeCompare(b.providerName));
+  }, [externalModels, query]);
+
+  return (
+    
+
+ + setQuery(event.target.value)} + placeholder="Search external models" + className="h-9 pl-8" + /> +
+
+
+ {grouped.length === 0 ? ( +
+ No external models configured. +
+ ) : ( + grouped.map((group) => ( +
+
+ + {group.providerName} +
+ {group.models.map((model) => ( + + ))} +
+ )) + )} +
+
+
+ ); +} diff --git a/studio/frontend/src/components/assistant-ui/model-selector/types.ts b/studio/frontend/src/components/assistant-ui/model-selector/types.ts index 3da75b4d4e..4cc5d779ce 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/types.ts +++ b/studio/frontend/src/components/assistant-ui/model-selector/types.ts @@ -18,8 +18,15 @@ export interface LoraModelOption extends ModelOption { exportType?: "lora" | "merged" | "gguf"; } +export interface ExternalModelOption extends ModelOption { + providerId: string; + providerName: string; + /** Registry key (e.g. openai, gemini) for provider branding. */ + providerType: string; +} + export interface ModelSelectorChangeMeta { - source: "hub" | "lora" | "exported" | "local"; + source: "hub" | "lora" | "exported" | "local" | "external"; isLora: boolean; ggufVariant?: string; isDownloaded?: boolean; diff --git a/studio/frontend/src/components/assistant-ui/reasoning.tsx b/studio/frontend/src/components/assistant-ui/reasoning.tsx index fe913baf2a..e4401cc12e 100644 --- a/studio/frontend/src/components/assistant-ui/reasoning.tsx +++ b/studio/frontend/src/components/assistant-ui/reasoning.tsx @@ -19,10 +19,8 @@ import { useAuiState, } from "@assistant-ui/react"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; -import { Idea01Icon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; import { type VariantProps, cva } from "class-variance-authority"; -import { ChevronDownIcon, CopyIcon, CheckIcon } from "lucide-react"; +import { CheckIcon, ChevronDownIcon, CopyIcon, LightbulbIcon } from "lucide-react"; import { type CSSProperties, type ComponentProps, @@ -128,10 +126,7 @@ function ReasoningTrigger({ )} {...props} > - + { }; const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => { + const [currentEmoji, setCurrentEmoji] = useState("large sloth drink.png"); + + useEffect(() => { + const hour = new Date().getHours(); + if (hour >= 6 && hour < 12) setCurrentEmoji("large sloth drink.png"); + else if (hour >= 12 && hour < 17) setCurrentEmoji("sloth magnify final.png"); + else if (hour >= 17 && hour < 21) setCurrentEmoji("sloth shy large.png"); + else setCurrentEmoji("unsloth-gem.png"); + }, []); + + const currentEmojiSrc = + currentEmoji === "unsloth-gem.png" + ? `/${currentEmoji}` + : `/Sloth emojis/${currentEmoji}`; + return (
Sloth mascot @@ -459,15 +478,76 @@ const ReasoningToggle: FC = () => { const modelLoaded = useChatRuntimeStore( (s) => !!s.params.checkpoint && !s.modelLoading, ); + const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint); const supportsReasoning = useChatRuntimeStore((s) => s.supportsReasoning); + const reasoningAlwaysOn = useChatRuntimeStore((s) => s.reasoningAlwaysOn); const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled); const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled); const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle); const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort); + const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff); + const reasoningEffortLevels = useChatRuntimeStore((s) => s.reasoningEffortLevels); const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort); - const disabled = !(modelLoaded && supportsReasoning); + const lastOpenRouterChosenModel = useChatRuntimeStore( + (s) => s.lastOpenRouterChosenModel, + ); + 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 toolsEnabled = useChatRuntimeStore((s) => s.toolsEnabled); + const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled); + const effectiveExternalModelId = + selectedExternalProvider?.providerType === "openrouter" && + externalSelection?.modelId === "openrouter/free" && + lastOpenRouterChosenModel + ? lastOpenRouterChosenModel + : externalSelection?.modelId; + const externalReasoningCaps = + externalSelection != null + ? getExternalReasoningCapabilities( + selectedExternalProvider?.providerType, + effectiveExternalModelId, + { + isReasoningProvider: + selectedExternalProvider?.isReasoningModel === true, + }, + ) + : null; + const effectiveReasoningStyle = + externalReasoningCaps?.reasoningStyle ?? reasoningStyle; + const effectiveReasoningAlwaysOn = + externalReasoningCaps?.reasoningAlwaysOn ?? reasoningAlwaysOn; + const effectiveSupportsReasoningOff = + externalReasoningCaps?.supportsReasoningOff ?? supportsReasoningOff; + const effectiveReasoningEffortLevels = + externalReasoningCaps?.reasoningEffortLevels ?? reasoningEffortLevels; + const effectiveSupportsReasoning = + externalReasoningCaps?.supportsReasoning ?? supportsReasoning; + const reasoningLockedOn = + effectiveSupportsReasoning && + (effectiveReasoningAlwaysOn || !effectiveSupportsReasoningOff); + const effectiveReasoningEnabled = reasoningLockedOn ? true : reasoningEnabled; + const effectiveReasoningVisualEnabled = + effectiveReasoningEnabled && reasoningEffort !== "none"; + const disabled = !(modelLoaded && effectiveSupportsReasoning); + const formatEffortLabel = (level: typeof reasoningEffort): string => { + if (level !== "xhigh") return level.charAt(0).toUpperCase() + level.slice(1); + const normalized = externalSelection?.modelId?.trim().toLowerCase() ?? ""; + if ( + normalized.startsWith("claude-opus-4-6") || + normalized.startsWith("claude-sonnet-4-6") + ) { + return "Max"; + } + return "Extra High"; + }; + const effortLabel = formatEffortLabel(reasoningEffort); - if (reasoningStyle === "reasoning_effort") { + if (effectiveReasoningStyle === "reasoning_effort") { return ( @@ -478,26 +558,52 @@ const ReasoningToggle: FC = () => { "flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors", disabled ? "cursor-not-allowed opacity-40" - : "bg-primary/10 text-primary hover:bg-primary/20", + : effectiveReasoningVisualEnabled + ? "bg-primary/10 text-primary hover:bg-primary/20" + : "text-muted-foreground hover:bg-muted-foreground/15", )} aria-label={`Reasoning effort: ${reasoningEffort}`} > - + {effectiveReasoningVisualEnabled ? ( + + ) : ( + + )} - Think:{" "} - {reasoningEffort.charAt(0).toUpperCase() + - reasoningEffort.slice(1)} + Think: {effectiveReasoningVisualEnabled ? effortLabel : "None"} - {(["low", "medium", "high"] as const).map((level) => ( + {effectiveSupportsReasoningOff && ( + { + setReasoningEnabled(false); + applyQwenThinkingParams(false); + }} + > + None + {!effectiveReasoningVisualEnabled ? " \u2713" : ""} + + )} + {effectiveReasoningEffortLevels + .filter((level) => level !== "none") + .map((level) => ( setReasoningEffort(level)} + onSelect={() => { + 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); + } + }} > - {level.charAt(0).toUpperCase() + level.slice(1)} - {reasoningEffort === level ? " \u2713" : ""} + {formatEffortLabel(level)} + {effectiveReasoningVisualEnabled && reasoningEffort === level ? " \u2713" : ""} ))} @@ -508,17 +614,39 @@ const ReasoningToggle: FC = () => { return ( +
+ + Connections + + + + {editingProviderId ? "Edit" : "New"} + +
+ + +
+
+
+
+
+ +

+ Supported registry or local OpenAI-compatible connection. +

+
+ +
+ + {showApiKeyField ? ( +
+
+ +

+ Stored locally. +

+
+
+ setApiKey(event.target.value)} + placeholder="Enter API key" + className="h-9 pr-9 text-sm" + /> + +
+
+ ) : null} + + {isCustomProvider ? ( +
+ + + setCustomProviderName(event.target.value) + } + placeholder="Custom" + className="h-9 text-sm" + /> +
+ ) : null} + + {isCustomProvider ? ( +
+
+ +

+ OpenAI-compatible endpoint. +

+
+ setBaseUrlDraft(event.target.value)} + placeholder={customProviderBaseUrlPlaceholder(providerType)} + className="h-9 text-sm" + /> +
+ ) : null} + + {showReasoningToggle ? ( +
+ + +
+ ) : null} +
+
+ +
+ + +
+
+ +

+ {modelStatusLabel} +

+
+ +
+ {isCustomProvider ? ( +
+
+ +