Merge branch 'main' into studio/hardening-followup

Resolves the conflict in `studio/backend/main.py` `_csp_header()`. Both
sides widened the Content-Security-Policy for Hugging Face endpoints:

* This branch (9d693b35) extended `img-src` to cover
  `https://huggingface.co` and `https://cdn-avatars.huggingface.co`
  (so the model picker can render owner avatars) and broadened
  `connect-src` to cover `*.huggingface.co`, `cdn-lfs.huggingface.co`,
  `cdn-lfs.hf.co`, `hf.co`, and `*.hf.co` (so the picker can resolve
  LFS file metadata).
* `main` added `https://datasets-server.huggingface.co` to
  `connect-src` for the dataset picker.

Kept both: the new `img-src` and `connect-src` allowlist now includes
the union of every HF origin the frontend touches.

Verified post-merge:

- `pytest studio/backend/tests --deselect test_studio_api.py`:
  1119 passed, 46 skipped, 0 failed.
- CSP string in `main.py` parses cleanly and contains all three
  additions (`t3.gstatic.com https://huggingface.co`,
  `cdn-avatars.huggingface.co`, `datasets-server.huggingface.co`).
This commit is contained in:
Daniel Han 2026-05-16 07:01:58 +00:00
commit 6a7dad86d1
133 changed files with 16984 additions and 1430 deletions

107
.github/scripts/hf-download-with-retry.sh vendored Executable file
View file

@ -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

View file

@ -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_<x>.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:

View file

@ -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:

View file

@ -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 ==="

View file

@ -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' }

View file

@ -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

View file

@ -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
# <base-sha>: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:

View file

@ -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'

View file

@ -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:

View file

@ -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`,

View file

@ -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'

View file

@ -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'

View file

@ -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

View file

@ -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'

View file

@ -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:

View file

@ -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

View file

@ -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'

View file

@ -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:

View file

@ -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'

View file

@ -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()

View file

@ -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'

View file

@ -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:

View file

@ -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'

View file

@ -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:

View file

@ -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())

File diff suppressed because it is too large Load diff

View file

@ -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")

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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"
)

View file

@ -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"),

View file

@ -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

View file

@ -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"])

View file

@ -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 ────────────────────────────────────

View file

@ -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)"
)

View file

@ -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"""

View file

@ -16,3 +16,5 @@ huggingface-hub==0.36.2
structlog>=24.1.0
diceware
ddgs
cryptography>=42.0.0
httpx>=0.27.0

View file

@ -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",
]

View file

@ -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

View file

@ -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()

View file

@ -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,

View file

@ -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()

View file

@ -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"

View file

@ -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: "<level>"}`` 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
``<think>...</think>`` chat-completion chunks so the frontend's
reasoning-panel pipeline lifts it correctly.
- The ``<think>`` 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 <think>...</think>, followed by the
# answer text, and the stream should terminate with [DONE].
assert "<think>First I plan.</think>" 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 <think> 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 == "<think>internal</think>"

View file

@ -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(),
)

View file

@ -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("<Q", len(kb)) + kb
body += struct.pack("<I", 8) # STRING vtype
body += struct.pack("<Q", len(vb)) + vb
header = struct.pack("<IIQQ", _GGUF_MAGIC, 3, 0, len(fields))
path.parent.mkdir(parents = True, exist_ok = True)
path.write_bytes(header + body)
return path
def _touch(path: Path) -> 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())

View file

@ -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("<Q", len(b)) + b
def _enc_kv_string(key: str, value: str) -> bytes:
return _enc_string(key) + struct.pack("<I", _VTYPE_STRING) + _enc_string(value)
def _enc_kv_uint32(key: str, value: int) -> bytes:
return (
_enc_string(key) + struct.pack("<I", _VTYPE_UINT32) + struct.pack("<I", value)
)
def _enc_kv_string_array(key: str, values: Iterable[str]) -> bytes:
vals = list(values)
out = _enc_string(key) + struct.pack("<I", _VTYPE_ARRAY)
out += struct.pack("<I", _VTYPE_STRING) + struct.pack("<Q", len(vals))
for v in vals:
out += _enc_string(v)
return out
def _write_synthetic_gguf(
path: Path,
general_strings: Mapping[str, str],
*,
extra_uint32: Mapping[str, int] | None = None,
extra_string_arrays: Mapping[str, Iterable[str]] | None = None,
) -> 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(
"<IIQQ",
_GGUF_MAGIC,
3, # version
0, # tensor_count
kv_count,
)
path.parent.mkdir(parents = True, exist_ok = True)
path.write_bytes(header + body)
return path
# --- read_gguf_general_metadata ----------------------------------------
def test_returns_none_for_missing_file(tmp_path: Path):
assert read_gguf_general_metadata(str(tmp_path / "nope.gguf")) is None
def test_returns_none_for_non_gguf(tmp_path: Path):
p = tmp_path / "garbage.gguf"
p.write_bytes(b"not a gguf file at all, just bytes")
assert read_gguf_general_metadata(str(p)) is None
def test_extracts_general_string_fields(tmp_path: Path):
p = _write_synthetic_gguf(
tmp_path / "model.gguf",
{
"general.architecture": "qwen2vl",
"general.type": "model",
"general.basename": "Qwen3.5",
"general.organization": "Qwen",
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
"general.base_model.0.name": "Qwen3.5 9B",
"general.base_model.0.organization": "Qwen",
},
)
meta = read_gguf_general_metadata(str(p))
assert meta is not None
assert meta["general.architecture"] == "qwen2vl"
assert meta["general.basename"] == "Qwen3.5"
assert (
meta["general.base_model.0.repo_url"]
== "https://huggingface.co/Qwen/Qwen3.5-9B"
)
def test_skips_unrelated_fields_without_breaking(tmp_path: Path):
"""Skip unwanted arrays and uint32s without losing position."""
p = _write_synthetic_gguf(
tmp_path / "model.gguf",
{"general.basename": "Foo"},
extra_uint32 = {"qwen2vl.context_length": 32768},
extra_string_arrays = {"tokenizer.ggml.tokens": ["a", "bc", "def"]},
)
meta = read_gguf_general_metadata(str(p))
assert meta == {"general.basename": "Foo"}
def test_metadata_is_cached(tmp_path: Path):
"""Cache invalidates on size change."""
p = _write_synthetic_gguf(
tmp_path / "model.gguf",
{"general.basename": "First"},
)
first = read_gguf_general_metadata(str(p))
assert first == {"general.basename": "First"}
# Force size change so the (path, mtime, size) key invalidates.
_write_synthetic_gguf(
tmp_path / "model.gguf",
{"general.basename": "Second", "general.organization": "X"},
)
second = read_gguf_general_metadata(str(p))
assert second == {"general.basename": "Second", "general.organization": "X"}
# --- is_mmproj_by_metadata --------------------------------------------
def test_is_mmproj_by_metadata_signals():
assert is_mmproj_by_metadata({"general.type": "mmproj"}) is True
assert is_mmproj_by_metadata({"general.type": "MMProj"}) is True
assert is_mmproj_by_metadata({"general.type": "model"}) is False
assert is_mmproj_by_metadata({"general.basename": "foo"}) is None
assert is_mmproj_by_metadata({}) is None
assert is_mmproj_by_metadata(None) is None
# --- pairing_score -----------------------------------------------------
def test_pairing_score_base_model_url_match():
weight = {
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
}
mmproj = {
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
}
assert pairing_score(weight, mmproj) == 100
def test_pairing_score_base_model_url_mismatch():
weight = {
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
}
mmproj = {
"general.base_model.0.repo_url": "https://huggingface.co/google/gemma-3-9B",
}
assert pairing_score(weight, mmproj) == -1
def test_pairing_score_base_model_url_trailing_slash_normalised():
weight = {
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B/",
}
mmproj = {
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
}
assert pairing_score(weight, mmproj) == 100
def test_pairing_score_basename_plus_org_fallback():
weight = {
"general.basename": "Nanonets-Ocr-S",
"general.base_model.0.organization": "Nanonets",
}
mmproj = {
"general.basename": "Nanonets-Ocr-S",
"general.base_model.0.organization": "Nanonets",
}
assert pairing_score(weight, mmproj) == 80
def test_pairing_score_basename_only_fallback():
assert (
pairing_score(
{"general.basename": "Nanonets-Ocr-S"},
{"general.basename": "Nanonets-Ocr-S"},
)
== 60
)
def test_pairing_score_no_overlap_returns_zero():
"""One side empty: scorer punts to filename fallback."""
assert pairing_score({"general.basename": "Foo"}, {}) == 0
assert pairing_score({}, {"general.basename": "Foo"}) == 0
assert pairing_score(None, {"general.basename": "Foo"}) == 0

View file

@ -56,11 +56,14 @@ def _install_fake_fast_mlx(monkeypatch, calls):
return _DummyModel(), _DummyTokenizer()
unsloth_zoo_pkg = types.ModuleType("unsloth_zoo")
mlx_loader = types.ModuleType("unsloth_zoo.mlx_loader")
mlx_pkg = types.ModuleType("unsloth_zoo.mlx")
mlx_loader = types.ModuleType("unsloth_zoo.mlx.loader")
mlx_loader.FastMLXModel = _FastMLXModel
unsloth_zoo_pkg.mlx_loader = mlx_loader
unsloth_zoo_pkg.mlx = mlx_pkg
mlx_pkg.loader = mlx_loader
monkeypatch.setitem(sys.modules, "unsloth_zoo", unsloth_zoo_pkg)
monkeypatch.setitem(sys.modules, "unsloth_zoo.mlx_loader", mlx_loader)
monkeypatch.setitem(sys.modules, "unsloth_zoo.mlx", mlx_pkg)
monkeypatch.setitem(sys.modules, "unsloth_zoo.mlx.loader", mlx_loader)
def test_mlx_inference_text_load_forwards_studio_settings(monkeypatch):

View file

@ -37,6 +37,7 @@ def _load_worker_module():
for name in (
"direct_wheel_url",
"flash_attn_wheel_url",
"has_blackwell_gpu",
"install_wheel",
"probe_torch_wheel_env",
"url_exists",

View file

@ -0,0 +1,391 @@
# 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 OpenAI's server-side `shell` tool translation in
`_stream_openai_responses`.
Covers:
- Request body: ``enabled_tools=["code_execution"]`` on the OpenAI
cloud base_url appends ``{"type": "shell", "environment": {"type":
"container_auto"}}`` to ``tools``.
- Container reuse: when ``openai_code_exec_container_id`` is provided,
the outgoing ``environment.type`` flips to ``"container_reference"``
and the id propagates.
- Cloud guard: code_execution on a non-cloud base_url (e.g. a local
OpenAI-compat preset / ollama / llama.cpp / vLLM) does NOT add the
shell tool, preventing a guaranteed 400 from those servers.
- SSE translation: a `shell_call` + `shell_call_output` pair emits one
``_toolEvent`` `tool_start` (`tool_name="code_execution"`,
`arguments.kind="bash"`) and one `tool_end` whose `result` contains
the joined stdout from the shell_call_output entries.
- Container surfacing: container_id captured from
`response.completed.container_id` is emitted as a synthetic
`container_ready` `_toolEvent` (only when it differs from the
inbound id).
- Stale-container handling: 400 with "container expired" body emits a
`container_invalidated` event before propagating the error.
"""
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(base_url: str = "https://api.openai.com/v1") -> 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

View file

@ -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"<html>OK</html>")
_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

View file

@ -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: <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 "<think>plan</think>answer" in combined

View file

@ -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")

View file

@ -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",

View file

@ -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)

View file

@ -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}")

View file

@ -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("<IIQQ", head)
if magic != _GGUF_MAGIC:
return None
for _ in range(kv_count):
try:
klen_bytes = f.read(8)
if len(klen_bytes) < 8:
break
klen = struct.unpack("<Q", klen_bytes)[0]
if klen > 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("<I", vt_bytes)[0]
if vtype == 8 and key in _WANTED_GENERAL_KEYS:
slen_bytes = f.read(8)
if len(slen_bytes) < 8:
break
slen = struct.unpack("<Q", slen_bytes)[0]
if slen > 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("<Q", slen_bytes)[0]
if slen > 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("<IQ", head)
if alen > 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("<Q", slen_bytes)[0]
if slen > 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

View file

@ -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: "<audio_soft_token>" 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("<custom_token_"))
> 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("<custom_token_")) > 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

View file

@ -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"):

View file

@ -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",

View file

@ -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",

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="900" viewBox="0 0 900 900">
<g>
<path d="M 222.16 664.50 L 212.35 691.50 L 206.42 691.53 C163.39,691.75 102.00,690.80 102.00,689.92 C102.00,689.34 103.32,685.63 104.94,681.68 C106.56,677.73 124.74,632.20 145.34,580.50 C165.94,528.80 205.07,430.70 232.29,362.50 C259.51,294.30 284.65,231.20 288.14,222.28 L 294.50 206.05 L 404.68 206.00 L 405.94 208.75 C407.23,211.56 413.90,227.98 437.50,286.50 C444.82,304.65 453.36,325.80 456.49,333.50 C459.61,341.20 475.70,381.02 492.24,422.00 C508.78,462.98 528.13,510.90 535.25,528.50 C542.36,546.10 553.72,574.22 560.48,591.00 C567.25,607.78 575.81,628.92 579.50,638.00 C585.05,651.65 598.92,686.11 600.73,690.76 C601.12,691.76 590.44,691.97 547.95,691.76 L 494.69 691.50 L 489.31 678.00 C486.35,670.58 481.94,659.33 479.52,653.00 C477.10,646.67 471.91,633.17 468.00,623.00 C464.08,612.83 459.67,601.24 458.19,597.25 L 455.51 590.00 L 249.31 590.00 L 245.56 600.25 C237.09,623.44 231.46,638.87 222.16,664.50 ZM 798.00 691.05 C798.00,691.64 777.31,692.00 743.50,692.00 C703.78,692.00 689.00,691.69 689.00,690.87 C689.00,690.26 685.87,681.82 682.04,672.12 C678.21,662.43 670.76,643.47 665.49,630.00 C660.21,616.53 652.36,596.50 648.03,585.50 C638.84,562.16 624.19,524.76 620.02,514.00 C615.63,502.69 600.65,464.57 593.49,446.50 C590.00,437.70 582.57,418.80 576.98,404.50 C562.77,368.16 549.76,334.97 543.27,318.50 C540.24,310.80 536.29,300.67 534.50,296.00 C532.71,291.33 526.45,275.35 520.59,260.50 C514.73,245.65 507.70,227.83 504.97,220.91 C502.23,213.98 500.00,207.85 500.00,207.29 C500.00,204.88 522.83,204.39 576.12,205.66 L 603.75 206.32 L 624.52 257.91 C635.94,286.28 646.96,313.77 649.01,319.00 C651.05,324.23 663.76,355.95 677.26,389.50 C699.90,445.80 737.77,540.07 780.62,646.80 C790.18,670.61 798.00,690.53 798.00,691.05 ZM 285.33 500.42 C285.62,501.18 305.07,501.42 351.82,501.24 L 417.90 500.97 L 415.37 494.24 C413.98,490.53 410.81,482.33 408.32,476.00 C405.83,469.67 403.42,463.38 402.98,462.00 C402.53,460.62 398.95,451.17 395.03,441.00 C391.11,430.83 384.57,413.73 380.50,403.00 C376.42,392.27 369.89,375.17 365.97,365.00 C362.05,354.83 357.46,342.77 355.77,338.20 C354.08,333.64 352.38,330.26 352.00,330.70 C351.61,331.14 347.21,341.85 342.21,354.50 C333.07,377.64 317.55,416.89 296.36,470.42 C290.06,486.32 285.10,499.82 285.33,500.42 Z" fill="rgb(37,37,36)"/>
<path d="M 0.00 450.00 L 0.00 0.00 L 450.00 0.00 L 900.00 0.00 L 900.00 450.00 L 900.00 900.00 L 450.00 900.00 L 0.00 900.00 L 0.00 450.00 ZM 222.16 664.50 C231.46,638.87 237.09,623.44 245.56,600.25 L 249.31 590.00 L 352.41 590.00 L 455.51 590.00 L 458.19 597.25 C459.67,601.24 464.08,612.83 468.00,623.00 C471.91,633.17 477.10,646.67 479.52,653.00 C481.94,659.33 486.35,670.58 489.31,678.00 L 494.69 691.50 L 547.95 691.76 C590.44,691.97 601.12,691.76 600.73,690.76 C598.92,686.11 585.05,651.65 579.50,638.00 C575.81,628.92 567.25,607.78 560.48,591.00 C553.72,574.22 542.36,546.10 535.25,528.50 C528.13,510.90 508.78,462.98 492.24,422.00 C475.70,381.02 459.61,341.20 456.49,333.50 C453.36,325.80 444.82,304.65 437.50,286.50 C413.90,227.98 407.23,211.56 405.94,208.75 L 404.68 206.00 L 349.59 206.03 L 294.50 206.05 L 288.14 222.28 C284.65,231.20 259.51,294.30 232.29,362.50 C205.07,430.70 165.94,528.80 145.34,580.50 C124.74,632.20 106.56,677.73 104.94,681.68 C103.32,685.63 102.00,689.34 102.00,689.92 C102.00,690.80 163.39,691.75 206.42,691.53 L 212.35 691.50 L 222.16 664.50 ZM 798.00 691.05 C798.00,690.53 790.18,670.61 780.62,646.80 C737.77,540.07 699.90,445.80 677.26,389.50 C663.76,355.95 651.05,324.23 649.01,319.00 C646.96,313.77 635.94,286.28 624.52,257.91 L 603.75 206.32 L 576.12 205.66 C522.83,204.39 500.00,204.88 500.00,207.29 C500.00,207.85 502.23,213.98 504.97,220.91 C507.70,227.83 514.73,245.65 520.59,260.50 C526.45,275.35 532.71,291.33 534.50,296.00 C536.29,300.67 540.24,310.80 543.27,318.50 C549.76,334.97 562.77,368.16 576.98,404.50 C582.57,418.80 590.00,437.70 593.49,446.50 C600.65,464.57 615.63,502.69 620.02,514.00 C624.19,524.76 638.84,562.16 648.03,585.50 C652.36,596.50 660.21,616.53 665.49,630.00 C670.76,643.47 678.21,662.43 682.04,672.12 C685.87,681.82 689.00,690.26 689.00,690.87 C689.00,691.69 703.78,692.00 743.50,692.00 C777.31,692.00 798.00,691.64 798.00,691.05 ZM 285.33 500.42 C285.10,499.82 290.06,486.32 296.36,470.42 C317.55,416.89 333.07,377.64 342.21,354.50 C347.21,341.85 351.61,331.14 352.00,330.70 C352.38,330.26 354.08,333.64 355.77,338.20 C357.46,342.77 362.05,354.83 365.97,365.00 C369.89,375.17 376.42,392.27 380.50,403.00 C384.57,413.73 391.11,430.83 395.03,441.00 C398.95,451.17 402.53,460.62 402.98,462.00 C403.42,463.38 405.83,469.67 408.32,476.00 C410.81,482.33 413.98,490.53 415.37,494.24 L 417.90 500.97 L 351.82 501.24 C305.07,501.42 285.62,501.18 285.33,500.42 Z" fill="rgb(209,155,118)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 377.1 277.86">
<defs>
<style>
.cls-1 {
fill: #4d6bfe;
stroke-width: 0px;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path id="path" class="cls-1" d="M373.15,23.32c-4-1.95-5.72,1.77-8.06,3.66-.79.62-1.47,1.43-2.14,2.14-5.85,6.26-12.67,10.36-21.57,9.86-13.04-.71-24.16,3.38-33.99,13.37-2.09-12.31-9.04-19.66-19.6-24.38-5.54-2.45-11.13-4.9-14.99-10.23-2.71-3.78-3.44-8-4.81-12.16-.85-2.51-1.72-5.09-4.6-5.52-3.13-.5-4.36,2.14-5.58,4.34-4.93,8.99-6.82,18.92-6.65,28.97.43,22.58,9.97,40.56,28.89,53.37,2.16,1.46,2.71,2.95,2.03,5.09-1.29,4.4-2.82,8.68-4.19,13.09-.85,2.82-2.14,3.44-5.15,2.2-10.39-4.34-19.37-10.76-27.29-18.55-13.46-13.02-25.63-27.41-40.81-38.67-3.57-2.64-7.12-5.09-10.81-7.41-15.49-15.07,2.03-27.45,6.08-28.9,4.25-1.52,1.47-6.79-12.23-6.73-13.69.06-26.24,4.65-42.21,10.76-2.34.93-4.79,1.61-7.32,2.14-14.5-2.73-29.55-3.35-45.29-1.58-29.62,3.32-53.28,17.34-70.68,41.28C1.29,88.2-3.63,120.88,2.39,155c6.33,35.91,24.64,65.68,52.8,88.94,29.18,24.1,62.8,35.91,101.15,33.65,23.29-1.33,49.23-4.46,78.48-29.24,7.38,3.66,15.12,5.12,27.97,6.23,9.89.93,19.41-.5,26.79-2.02,11.55-2.45,10.75-13.15,6.58-15.13-33.87-15.78-26.44-9.36-33.2-14.54,17.21-20.41,43.15-41.59,53.3-110.19.79-5.46.11-8.87,0-13.3-.06-2.67.54-3.72,3.61-4.03,8.48-.96,16.72-3.29,24.28-7.47,21.94-12,30.78-31.69,32.87-55.33.31-3.6-.06-7.35-3.86-9.24ZM181.96,235.97c-32.83-25.83-48.74-34.33-55.31-33.96-6.14.34-5.04,7.38-3.69,11.97,1.41,4.53,3.26,7.66,5.85,11.63,1.78,2.64,3.01,6.57-1.78,9.49-10.57,6.58-28.95-2.2-29.82-2.64-21.38-12.59-39.26-29.24-51.87-52.01-12.16-21.92-19.23-45.43-20.39-70.52-.31-6.08,1.47-8.22,7.49-9.3,7.92-1.46,16.11-1.77,24.03-.62,33.49,4.9,62.01,19.91,85.9,43.63,13.65,13.55,23.97,29.71,34.61,45.49,11.3,16.78,23.48,32.75,38.97,45.84,5.46,4.59,9.83,8.09,14,10.67-12.59,1.4-33.62,1.71-47.99-9.68ZM197.69,134.65c0-2.7,2.15-4.84,4.87-4.84.6,0,1.16.12,1.66.31.67.25,1.29.62,1.77,1.18.87.84,1.36,2.08,1.36,3.35,0,2.7-2.15,4.84-4.85,4.84s-4.81-2.14-4.81-4.84ZM246.55,159.77c-3.13,1.27-6.26,2.39-9.27,2.51-4.67.22-9.77-1.68-12.55-4-4.3-3.6-7.36-5.61-8.67-11.94-.54-2.7-.23-6.85.25-9.24,1.12-5.15-.12-8.44-3.74-11.44-2.96-2.45-6.7-3.1-10.82-3.1-1.54,0-2.95-.68-4-1.24-1.72-.87-3.13-3.01-1.78-5.64.43-.84,2.53-2.92,3.02-3.29,5.58-3.19,12.03-2.14,18,.25,5.54,2.26,9.71,6.42,15.72,12.28,6.16,7.1,7.26,9.09,10.76,14.39,2.76,4.19,5.29,8.47,7.01,13.37,1.04,3.04-.31,5.55-3.94,7.1Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 3 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="m356.4 201.3-32.8 58.3c-43.3-33.3-107.4-38.2-150.7-2.4-69.8 57.6-64.9 190.8 43.7 191.6 30.4 0 56.2-14.3 83.9-23.8l14.6 58.1c-24.6 11.4-49.6 23.1-76.6 26.7-246 33.5-231.9-321.6-9.5-340.1 46.7-3.9 87.8 8.3 127.6 31.6zm-169.9-55.9c-37.4 11.2-72.2 31.8-98.5 60.8-4.9-58.8 8.3-177.7 73.7-201 9.7-3.4 43-11.9 42.1 5.3-1 17.3-24.1 46.9-29.7 63-9.7 28.2-.7 47.6 12.6 72.2zm92.4 252.8h-36.5v-41.3h-41.3v-34h37.7l3.6-3.6v-40.1h36.5V323h38.9v34h-38.9zm133.7-41.3v41.3h-36.5v-41.3h-38.9v-34h38.9v-43.8h36.5v40.1l3.6 3.6h37.7v34h-41.3zM305.4 31.4c4.9 7.3-22.6 38.7-27 46.7-12.6 23.8-4.1 37.4 5.3 60-27.5-4.1-53-.7-80.2 2.4C209.6 88.3 239 12.2 305.4 31.4" style="fill:#ff8236"/></svg>

After

Width:  |  Height:  |  Size: 763 B

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:cc="http://creativecommons.org/ns#" width="287.56" height="191">
<desc>Logo of Meta Platforms -- Graphic created by Detmar Owen</desc>
<defs>
<linearGradient id="Grad_Logo1" x1="61" y1="117" x2="259" y2="127" gradientUnits="userSpaceOnUse">
<stop style="stop-color:#0064e1" offset="0"/>
<stop style="stop-color:#0064e1" offset="0.4"/>
<stop style="stop-color:#0073ee" offset="0.83"/>
<stop style="stop-color:#0082fb" offset="1"/>
</linearGradient>
<linearGradient id="Grad_Logo2" x1="45" y1="139" x2="45" y2="66" gradientUnits="userSpaceOnUse">
<stop style="stop-color:#0082fb" offset="0"/>
<stop style="stop-color:#0064e0" offset="1"/>
</linearGradient>
</defs>
<path id="Logo0" style="fill:#0081fb" d="m31.06,125.96c0,10.98 2.41,19.41 5.56,24.51 4.13,6.68 10.29,9.51 16.57,9.51 8.1,0 15.51-2.01 29.79-21.76 11.44-15.83 24.92-38.05 33.99-51.98l15.36-23.6c10.67-16.39 23.02-34.61 37.18-46.96 11.56-10.08 24.03-15.68 36.58-15.68 21.07,0 41.14,12.21 56.5,35.11 16.81,25.08 24.97,56.67 24.97,89.27 0,19.38-3.82,33.62-10.32,44.87-6.28,10.88-18.52,21.75-39.11,21.75l0-31.02c17.63,0 22.03-16.2 22.03-34.74 0-26.42-6.16-55.74-19.73-76.69-9.63-14.86-22.11-23.94-35.84-23.94-14.85,0-26.8,11.2-40.23,31.17-7.14,10.61-14.47,23.54-22.7,38.13l-9.06,16.05c-18.2,32.27-22.81,39.62-31.91,51.75-15.95,21.24-29.57,29.29-47.5,29.29-21.27,0-34.72-9.21-43.05-23.09-6.8-11.31-10.14-26.15-10.14-43.06z"/>
<path id="Logo1" style="fill:url(#Grad_Logo1)" d="m24.49,37.3c14.24-21.95 34.79-37.3 58.36-37.3 13.65,0 27.22,4.04 41.39,15.61 15.5,12.65 32.02,33.48 52.63,67.81l7.39,12.32c17.84,29.72 27.99,45.01 33.93,52.22 7.64,9.26 12.99,12.02 19.94,12.02 17.63,0 22.03-16.2 22.03-34.74l27.4-.86c0,19.38-3.82,33.62-10.32,44.87-6.28,10.88-18.52,21.75-39.11,21.75-12.8,0-24.14-2.78-36.68-14.61-9.64-9.08-20.91-25.21-29.58-39.71l-25.79-43.08c-12.94-21.62-24.81-37.74-31.68-45.04-7.39-7.85-16.89-17.33-32.05-17.33-12.27,0-22.69,8.61-31.41,21.78z"/>
<path id="Logo2" style="fill:url(#Grad_Logo2)" d="m82.35,31.23c-12.27,0-22.69,8.61-31.41,21.78-12.33,18.61-19.88,46.33-19.88,72.95 0,10.98 2.41,19.41 5.56,24.51l-26.48,17.44c-6.8-11.31-10.14-26.15-10.14-43.06 0-30.75 8.44-62.8 24.49-87.55 14.24-21.95 34.79-37.3 58.36-37.3z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23"><path fill="#f3f3f3" d="M0 0h23v23H0z"/><path fill="#f35325" d="M1 1h10v10H1z"/><path fill="#81bc06" d="M12 1h10v10H12z"/><path fill="#05a6f0" d="M1 12h10v10H1z"/><path fill="#ffba08" d="M12 12h10v10H12z"/></svg>

After

Width:  |  Height:  |  Size: 272 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1 @@
<svg viewBox="0 0 271.7 179.7" xmlns="http://www.w3.org/2000/svg" width="2500" height="1653"><path d="M101.3 53.6V37.4c1.6-.1 3.2-.2 4.8-.2 44.4-1.4 73.5 38.2 73.5 38.2S148.2 119 114.5 119c-4.5 0-8.9-.7-13.1-2.1V67.7c17.3 2.1 20.8 9.7 31.1 27l23.1-19.4s-16.9-22.1-45.3-22.1c-3-.1-6 .1-9 .4m0-53.6v24.2l4.8-.3c61.7-2.1 102 50.6 102 50.6s-46.2 56.2-94.3 56.2c-4.2 0-8.3-.4-12.4-1.1v15c3.4.4 6.9.7 10.3.7 44.8 0 77.2-22.9 108.6-49.9 5.2 4.2 26.5 14.3 30.9 18.7-29.8 25-99.3 45.1-138.7 45.1-3.8 0-7.4-.2-11-.6v21.1h170.2V0H101.3zm0 116.9v12.8c-41.4-7.4-52.9-50.5-52.9-50.5s19.9-22 52.9-25.6v14h-.1c-17.3-2.1-30.9 14.1-30.9 14.1s7.7 27.3 31 35.2M27.8 77.4s24.5-36.2 73.6-40V24.2C47 28.6 0 74.6 0 74.6s26.6 77 101.3 84v-14c-54.8-6.8-73.5-67.2-73.5-67.2z" fill="#76b900"/></svg>

After

Width:  |  Height:  |  Size: 771 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 466.04 516.93"><polygon points="0.12 182.71 234.14 516.92 338.15 516.92 104.13 182.71 0.12 182.71"/><polygon points="0 516.92 104.08 516.92 156.08 442.67 104.04 368.34 0 516.92"/><polygon points="466.04 0 361.96 0 182.1 256.86 234.15 331.18 466.04 0"/><polygon points="380.78 516.92 466.04 516.92 466.04 37.16 380.78 158.92 380.78 516.92"/></svg>

After

Width:  |  Height:  |  Size: 399 B

View file

@ -0,0 +1,215 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0.0 0.0 30.0 30.0" style="enable-background:new 0 0 30 30;" xml:space="preserve" width="316.22776601683796" height="316.22776601683796">
<style type="text/css">
.st0{opacity:0.3;fill:#E2E4E7;}
.st1{opacity:0.8;fill:#E2E4E7;stroke:#FFFFFF;stroke-width:5;stroke-miterlimit:10;}
.st2{fill:url(#SVGID_1_);}
.st3{fill:none;stroke:#E0E4E9;stroke-width:0.25;stroke-miterlimit:10;}
.st4{fill:none;}
.st5{fill:#9DA1A5;}
.st6{fill-rule:evenodd;clip-rule:evenodd;fill:none;}
.st7{fill-rule:evenodd;clip-rule:evenodd;fill:#DFE2E7;}
.st8{fill-rule:evenodd;clip-rule:evenodd;fill:#CDD4DA;}
.st9{fill-rule:evenodd;clip-rule:evenodd;fill:#B3BCC7;}
.st10{fill-rule:evenodd;clip-rule:evenodd;fill:#9DAAB7;}
.st11{fill-rule:evenodd;clip-rule:evenodd;fill:#8698A8;}
.st12{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_2_);}
.st13{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_3_);}
.st14{fill:#1F63EC;}
.st15{fill:#2D2D2D;}
.st16{fill:none;stroke:#E0E4E9;stroke-width:0.5;stroke-miterlimit:10;}
.st17{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_4_);}
.st18{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_5_);}
.st19{fill:none;stroke:#677380;stroke-width:0.5;stroke-miterlimit:10;}
.st20{fill:none;stroke:url(#SVGID_6_);stroke-width:2;stroke-miterlimit:10;}
.st21{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_7_);}
.st22{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_8_);}
.st23{fill:#FFFFFF;}
.st24{fill-rule:evenodd;clip-rule:evenodd;fill:#2D2D2D;}
.st25{clip-path:url(#SVGID_10_);}
.st26{clip-path:url(#SVGID_12_);}
.st27{fill:url(#SVGID_13_);}
.st28{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_14_);}
.st29{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_15_);}
.st30{clip-path:url(#SVGID_17_);}
.st31{clip-path:url(#SVGID_19_);}
.st32{fill:url(#SVGID_20_);}
.st33{fill:none;stroke:url(#SVGID_21_);stroke-width:2;stroke-miterlimit:10;}
.st34{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_22_);}
.st35{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_23_);}
.st36{clip-path:url(#SVGID_25_);}
.st37{clip-path:url(#SVGID_27_);}
.st38{fill:url(#SVGID_28_);}
.st39{clip-path:url(#SVGID_30_);}
.st40{clip-path:url(#SVGID_32_);}
.st41{fill:url(#SVGID_33_);}
.st42{fill-rule:evenodd;clip-rule:evenodd;fill:#126EF6;}
.st43{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
.st44{clip-path:url(#SVGID_35_);}
.st45{clip-path:url(#SVGID_37_);}
.st46{fill:url(#SVGID_38_);}
.st47{fill-rule:evenodd;clip-rule:evenodd;fill:#9DA1A5;}
.st48{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_39_);}
.st49{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_40_);}
.st50{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_41_);}
.st51{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_42_);}
.st52{fill:none;stroke:url(#SVGID_43_);stroke-width:2;stroke-miterlimit:10;}
.st53{fill-rule:evenodd;clip-rule:evenodd;fill:none;stroke:#E0E4E9;stroke-width:0.5;stroke-miterlimit:10;}
.st54{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_44_);}
.st55{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_45_);}
.st56{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_46_);}
.st57{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_47_);}
.st58{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_48_);}
.st59{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_49_);}
.st60{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_50_);}
.st61{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_51_);}
.st62{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_52_);}
.st63{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_53_);}
.st64{clip-path:url(#SVGID_55_);}
.st65{clip-path:url(#SVGID_57_);}
.st66{fill:url(#SVGID_58_);}
.st67{clip-path:url(#SVGID_60_);}
.st68{clip-path:url(#SVGID_62_);}
.st69{fill:url(#SVGID_63_);}
.st70{fill:none;stroke:url(#SVGID_64_);stroke-width:2;stroke-miterlimit:10;}
.st71{clip-path:url(#SVGID_66_);}
.st72{clip-path:url(#SVGID_68_);}
.st73{fill:url(#SVGID_69_);}
.st74{clip-path:url(#SVGID_71_);}
.st75{clip-path:url(#SVGID_73_);}
.st76{fill:url(#SVGID_74_);}
.st77{clip-path:url(#SVGID_76_);}
.st78{clip-path:url(#SVGID_78_);}
.st79{fill:url(#SVGID_79_);}
.st80{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_80_);}
.st81{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_81_);}
.st82{clip-path:url(#SVGID_83_);}
.st83{clip-path:url(#SVGID_85_);}
.st84{fill:url(#SVGID_86_);}
.st85{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_87_);}
.st86{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_88_);}
.st87{clip-path:url(#SVGID_90_);}
.st88{clip-path:url(#SVGID_92_);}
.st89{fill:url(#SVGID_93_);}
.st90{fill:none;stroke:url(#SVGID_94_);stroke-width:2;stroke-miterlimit:10;}
.st91{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_95_);}
.st92{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_96_);}
.st93{clip-path:url(#SVGID_98_);}
.st94{clip-path:url(#SVGID_100_);}
.st95{fill:url(#SVGID_101_);}
.st96{clip-path:url(#SVGID_103_);}
.st97{clip-path:url(#SVGID_105_);}
.st98{fill:url(#SVGID_106_);}
.st99{clip-path:url(#SVGID_108_);}
.st100{clip-path:url(#SVGID_110_);}
.st101{fill:url(#SVGID_111_);}
.st102{fill:#FFFFFF;stroke:#B3BCC7;stroke-width:0.275;stroke-miterlimit:10;}
.st103{clip-path:url(#SVGID_113_);}
.st104{fill:#FDD138;}
.st105{fill:#FCA62F;}
.st106{fill:#FB7927;}
.st107{fill:#F44B22;}
.st108{fill:#D81915;}
.st109{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.3354;stroke-miterlimit:10;}
.st110{fill:none;stroke:#65727F;stroke-width:2;stroke-miterlimit:10;}
.st111{fill:none;stroke:#65727F;stroke-width:0.75;stroke-miterlimit:10;}
.st112{fill:url(#SVGID_114_);}
.st113{fill:#D06C50;}
.st114{fill:#2D2D2D;stroke:#B3BCC7;stroke-width:0.275;stroke-miterlimit:10;}
.st115{opacity:0.2;}
.st116{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;}
.st117{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0212,1.0212;}
.st118{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0205,1.0205;}
.st119{opacity:0.2;fill:none;}
.st120{fill:none;stroke:#677380;stroke-width:0.3689;stroke-miterlimit:10;}
.st121{fill:none;stroke:#677380;stroke-width:0.3689;stroke-miterlimit:10;stroke-dasharray:1.0509,1.0509;}
.st122{opacity:0.3;fill:#1F63EC;}
.st123{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.3162;stroke-miterlimit:10;}
.st124{fill:#FFFFFF;stroke:#B3BCC7;stroke-width:0.3162;stroke-miterlimit:10;}
.st125{clip-path:url(#SVGID_118_);}
.st126{fill:url(#SVGID_119_);}
.st127{fill:none;stroke:#DFE2E7;stroke-width:0.75;stroke-miterlimit:10;}
.st128{fill:#9DA1A5;stroke:#FFFFFF;stroke-miterlimit:10;}
.st129{fill:url(#SVGID_120_);}
.st130{fill:none;stroke:#677380;stroke-width:0.75;stroke-miterlimit:10;}
.st131{opacity:0.4;}
.st132{clip-path:url(#SVGID_122_);}
.st133{clip-path:url(#SVGID_124_);}
.st134{fill:url(#SVGID_125_);}
.st135{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;}
.st136{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:0.9951,0.9951;}
.st137{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:1.004,1.004;}
.st138{fill:none;stroke:url(#SVGID_126_);stroke-width:1.5;stroke-miterlimit:10;}
.st139{fill:url(#SVGID_127_);}
.st140{fill:none;stroke:#DDE0E4;stroke-width:0.35;stroke-miterlimit:10;}
.st141{fill:#2D2D2D;stroke:#A9B3BE;stroke-width:0.275;stroke-miterlimit:10;}
.st142{fill-rule:evenodd;clip-rule:evenodd;fill:#126EF4;}
.st143{fill:#FFFFFF;stroke:#B1BAC4;stroke-width:0.275;stroke-miterlimit:10;}
.st144{fill:#CE6C50;}
.st145{fill:#5B5B5B;}
.st146{fill:#8392A3;}
.st147{fill:none;stroke:url(#SVGID_128_);stroke-width:1.5;stroke-miterlimit:10;}
.st148{fill:url(#SVGID_129_);}
.st149{fill:none;stroke:#B5BDC4;stroke-width:0.7;stroke-miterlimit:10;}
.st150{opacity:0.6;fill:none;stroke:#78838E;stroke-width:0.35;stroke-miterlimit:10;}
.st151{opacity:0.2;fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:1,1;}
.st152{fill:none;stroke:#DDE0E4;stroke-width:0.75;stroke-miterlimit:10;}
.st153{fill:none;stroke:#8392A3;stroke-width:0.5;stroke-miterlimit:10;}
.st154{opacity:0.2;fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0182,1.0182;}
.st155{fill:none;stroke:#DDE0E4;stroke-width:0.765;stroke-miterlimit:10;}
.st156{fill:url(#SVGID_130_);}
.st157{fill:url(#SVGID_131_);}
.st158{fill:#B1BAC4;}
.st159{fill:#CBD1D8;}
.st160{fill:#0B1B2B;}
.st161{fill:#91D119;}
.st162{opacity:0.7;}
.st163{fill:#FFFFFF;stroke:#000000;stroke-width:0.4418;stroke-miterlimit:10;}
.st164{fill:none;stroke:#939CAA;stroke-width:0.2209;stroke-miterlimit:10;}
.st165{fill:none;stroke:#FFFFFF;stroke-width:3.0924;stroke-miterlimit:10;}
.st166{fill:url(#SVGID_132_);}
.st167{fill:none;stroke:url(#SVGID_133_);stroke-width:1.714;stroke-miterlimit:10;}
.st168{fill:url(#SVGID_134_);}
.st169{fill:url(#SVGID_135_);}
.st170{fill:url(#SVGID_136_);}
.st171{fill:url(#SVGID_137_);}
.st172{fill:url(#SVGID_138_);}
.st173{fill:url(#SVGID_139_);}
.st174{fill:url(#SVGID_140_);}
.st175{fill:url(#SVGID_141_);}
.st176{fill:url(#SVGID_142_);}
.st177{fill:url(#SVGID_143_);}
.st178{fill:url(#SVGID_144_);}
.st179{fill:none;stroke:#1F63EC;stroke-width:4;stroke-miterlimit:10;}
.st180{fill:none;stroke:#0B1B2B;stroke-width:4;stroke-miterlimit:10;}
.st181{fill:none;stroke:#677380;stroke-width:0.3989;stroke-miterlimit:10;}
.st182{fill:none;stroke:#677380;stroke-width:0.3989;stroke-miterlimit:10;stroke-dasharray:1.14,1.14;}
.st183{fill:#257AF1;}
.st184{opacity:0.3;fill:#FFFFFF;}
.st185{fill:none;stroke:#98A5B2;stroke-width:4;stroke-miterlimit:10;}
.st186{fill:none;stroke:#65727F;stroke-width:0.3989;stroke-miterlimit:10;}
.st187{fill:none;stroke:#65727F;stroke-width:0.3989;stroke-miterlimit:10;stroke-dasharray:1.14,1.14;}
.st188{fill:none;stroke:#DDDFE4;stroke-width:0.75;stroke-miterlimit:10;}
.st189{fill:#9A9EA2;}
.st190{fill-rule:evenodd;clip-rule:evenodd;fill:#3267AC;}
.st191{fill:#FFFFFF;stroke:#AFB8C3;stroke-width:0.275;stroke-miterlimit:10;}
.st192{fill:#C5694E;}
.st193{fill:#8192A2;}
.st194{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.6317;stroke-miterlimit:10;}
</style>
<g id="图层_2">
</g>
<g id="图层_1">
<path class="st194" d="M24.51,28.51H5.49c-2.21,0-4-1.79-4-4V5.49c0-2.21,1.79-4,4-4h19.03c2.21,0,4,1.79,4,4v19.03 C28.51,26.72,26.72,28.51,24.51,28.51z"/>
<g>
<g>
<g>
<g>
<path class="st23" d="M15.47,7.1l-1.3,1.85c-0.2,0.29-0.54,0.47-0.9,0.47h-7.1V7.09C6.16,7.1,15.47,7.1,15.47,7.1z"/>
<polygon class="st23" points="24.3,7.1 13.14,22.91 5.7,22.91 16.86,7.1 "/>
<path class="st23" d="M14.53,22.91l1.31-1.86c0.2-0.29,0.54-0.47,0.9-0.47h7.09v2.33H14.53z"/>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,19 @@
<svg width="191" height="135" viewBox="0 0 191 135" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_134_208)">
<path d="M54.3221 0H27.1531V27.0892H54.3221V0Z" fill="#FFD800"/>
<path d="M162.984 0H135.815V27.0892H162.984V0Z" fill="#FFD800"/>
<path d="M81.4823 27.0913H27.1531V54.1805H81.4823V27.0913Z" fill="#FFAF00"/>
<path d="M162.99 27.0913H108.661V54.1805H162.99V27.0913Z" fill="#FFAF00"/>
<path d="M162.972 54.168H27.1531V81.2572H162.972V54.168Z" fill="#FF8205"/>
<path d="M54.3221 81.2593H27.1531V108.349H54.3221V81.2593Z" fill="#FA500F"/>
<path d="M108.661 81.2593H81.4917V108.349H108.661V81.2593Z" fill="#FA500F"/>
<path d="M162.984 81.2593H135.815V108.349H162.984V81.2593Z" fill="#FA500F"/>
<path d="M81.4879 108.339H-0.00146484V135.429H81.4879V108.339Z" fill="#E10500"/>
<path d="M190.159 108.339H108.661V135.429H190.159V108.339Z" fill="#E10500"/>
</g>
<defs>
<clipPath id="clip0_134_208">
<rect width="190.141" height="135" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1,001 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.6 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 158.7128 157.296">
<!-- Generator: Adobe Illustrator 29.2.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 116) -->
<path d="M60.8734,57.2556v-14.9432c0-1.2586.4722-2.2029,1.5728-2.8314l30.0443-17.3023c4.0899-2.3593,8.9662-3.4599,13.9988-3.4599,18.8759,0,30.8307,14.6289,30.8307,30.2006,0,1.1007,0,2.3593-.158,3.6178l-31.1446-18.2467c-1.8872-1.1006-3.7754-1.1006-5.6629,0l-39.4812,22.9651ZM131.0276,115.4561v-35.7074c0-2.2028-.9446-3.7756-2.8318-4.8763l-39.481-22.9651,12.8982-7.3934c1.1007-.6285,2.0453-.6285,3.1458,0l30.0441,17.3024c8.6523,5.0341,14.4708,15.7296,14.4708,26.1107,0,11.9539-7.0769,22.965-18.2461,27.527v.0021ZM51.593,83.9964l-12.8982-7.5497c-1.1007-.6285-1.5728-1.5728-1.5728-2.8314v-34.6048c0-16.8303,12.8982-29.5722,30.3585-29.5722,6.607,0,12.7403,2.2029,17.9324,6.1349l-30.987,17.9324c-1.8871,1.1007-2.8314,2.6735-2.8314,4.8764v45.6159l-.0014-.0015ZM79.3562,100.0403l-18.4829-10.3811v-22.0209l18.4829-10.3811,18.4812,10.3811v22.0209l-18.4812,10.3811ZM91.2319,147.8591c-6.607,0-12.7403-2.2031-17.9324-6.1344l30.9866-17.9333c1.8872-1.1005,2.8318-2.6728,2.8318-4.8759v-45.616l13.0564,7.5498c1.1005.6285,1.5723,1.5728,1.5723,2.8314v34.6051c0,16.8297-13.0564,29.5723-30.5147,29.5723v.001ZM53.9522,112.7822l-30.0443-17.3024c-8.652-5.0343-14.471-15.7296-14.471-26.1107,0-12.1119,7.2356-22.9652,18.403-27.5272v35.8634c0,2.2028.9443,3.7756,2.8314,4.8763l39.3248,22.8068-12.8982,7.3938c-1.1007.6287-2.045.6287-3.1456,0ZM52.2229,138.5791c-17.7745,0-30.8306-13.3713-30.8306-29.8871,0-1.2585.1578-2.5169.3143-3.7754l30.987,17.9323c1.8871,1.1005,3.7757,1.1005,5.6628,0l39.4811-22.807v14.9435c0,1.2585-.4721,2.2021-1.5728,2.8308l-30.0443,17.3025c-4.0898,2.359-8.9662,3.4605-13.9989,3.4605h.0014ZM91.2319,157.296c19.0327,0,34.9188-13.5272,38.5383-31.4594,17.6164-4.562,28.9425-21.0779,28.9425-37.908,0-11.0112-4.719-21.7066-13.2133-29.4143.7867-3.3035,1.2595-6.607,1.2595-9.909,0-22.4929-18.2471-39.3247-39.3251-39.3247-4.2461,0-8.3363.6285-12.4262,2.045-7.0792-6.9213-16.8318-11.3254-27.5271-11.3254-19.0331,0-34.9191,13.5268-38.5384,31.4591C11.3255,36.0212,0,52.5373,0,69.3675c0,11.0112,4.7184,21.7065,13.2125,29.4142-.7865,3.3035-1.2586,6.6067-1.2586,9.9092,0,22.4923,18.2466,39.3241,39.3248,39.3241,4.2462,0,8.3362-.6277,12.426-2.0441,7.0776,6.921,16.8302,11.3251,27.5271,11.3251Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g clip-path="url(#prefix__clip0_8_13)"><path fill-rule="evenodd" clip-rule="evenodd" d="M358.485 41.75l154.027 87.573v1.856l-155.605 86.634.362-45.162-17.514-.64c-22.592-.598-34.368.042-48.384 2.346-22.699 3.734-43.478 12.31-67.136 28.843l-46.208 32.107c-6.059 4.16-10.56 7.168-14.507 9.706l-10.987 6.87-8.469 4.992 8.213 4.906 11.307 7.211c10.155 6.699 24.96 16.981 57.621 39.808 23.68 16.533 44.438 25.109 67.136 28.843l6.4.96c14.806 1.941 29.334 2.005 60.267.704l.469-46.059 154.027 87.573v1.856l-155.605 86.656.298-39.722-13.546.469c-29.568.896-45.59.043-66.944-3.456-36.139-5.973-69.547-19.755-104.128-43.925l-46.038-32a467.072 467.072 0 00-16.106-10.624l-9.963-5.974c-5.38-3.1-10.785-6.157-16.213-9.173C62.037 314.24 12.01 301.141 0 301.141v-90.197l2.987.085c12.032-.149 62.08-13.269 81.258-23.978l21.675-12.374 9.344-5.845c9.131-5.973 22.869-15.488 57.301-39.531 34.582-24.17 67.968-37.973 104.128-43.925 24.576-4.053 42.112-4.544 81.366-2.944l.426-40.683z" fill="#000"/></g><defs><clipPath id="prefix__clip0_8_13"><path fill="#fff" d="M0 0h512v512H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View file

@ -0,0 +1 @@
<svg version="1.1" viewBox="0.0 0.0 96.0 96.0" fill="none" stroke="none" stroke-linecap="square" stroke-miterlimit="10" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><clipPath id="g31e21232314_0_33.0"><path d="m0 0l96.0 0l0 96.0l-96.0 0l0 -96.0z" clip-rule="nonzero"/></clipPath><g clip-path="url(#g31e21232314_0_33.0)"><path fill="#d9d9d9" d="m41.04961 80.271324l1.8897629 0l0 2.3307114l-1.8897629 0z" fill-rule="evenodd"/><path fill="#d9d9d9" d="m42.221855 81.45145l1.8897629 0l0 2.3307037l-1.8897629 0z" fill-rule="evenodd"/><g filter="url(#shadowFilter-g31e21232314_0_33.1)"><use xlink:href="#g31e21232314_0_33.1" transform="matrix(1.0 0.0 0.0 1.0 0.0 2.0)"/></g><defs><filter id="shadowFilter-g31e21232314_0_33.1" filterUnits="userSpaceOnUse"><feGaussianBlur in="SourceAlpha" stdDeviation="2.0" result="blur"/><feComponentTransfer in="blur" color-interpolation-filters="sRGB"><feFuncR type="linear" slope="0" intercept="0.0"/><feFuncG type="linear" slope="0" intercept="0.0"/><feFuncB type="linear" slope="0" intercept="0.0"/><feFuncA type="linear" slope="0.0" intercept="0"/></feComponentTransfer></filter></defs><g id="g31e21232314_0_33.1"><path fill="#d9d9d9" d="m42.22417 28.470434l0 55.307083l-27.653543 -55.307083z" fill-rule="evenodd"/></g><g filter="url(#shadowFilter-g31e21232314_0_33.2)"><use xlink:href="#g31e21232314_0_33.2" transform="matrix(1.0 0.0 0.0 1.0 0.0 2.0)"/></g><defs><filter id="shadowFilter-g31e21232314_0_33.2" filterUnits="userSpaceOnUse"><feGaussianBlur in="SourceAlpha" stdDeviation="2.0" result="blur"/><feComponentTransfer in="blur" color-interpolation-filters="sRGB"><feFuncR type="linear" slope="0" intercept="0.0"/><feFuncG type="linear" slope="0" intercept="0.0"/><feFuncB type="linear" slope="0" intercept="0.0"/><feFuncA type="linear" slope="0.0" intercept="0"/></feComponentTransfer></filter></defs><g id="g31e21232314_0_33.2"><path fill="#d9d9d9" d="m42.223038 83.77752l21.729656 0l18.653545 -70.385826l-25.574802 13.461943z" fill-rule="evenodd"/></g><g filter="url(#shadowFilter-g31e21232314_0_33.3)"><use xlink:href="#g31e21232314_0_33.3" transform="matrix(1.0 0.0 0.0 1.0 0.0 2.0)"/></g><defs><filter id="shadowFilter-g31e21232314_0_33.3" filterUnits="userSpaceOnUse"><feGaussianBlur in="SourceAlpha" stdDeviation="2.0" result="blur"/><feComponentTransfer in="blur" color-interpolation-filters="sRGB"><feFuncR type="linear" slope="0" intercept="0.0"/><feFuncG type="linear" slope="0" intercept="0.0"/><feFuncB type="linear" slope="0" intercept="0.0"/><feFuncA type="linear" slope="0.0" intercept="0"/></feComponentTransfer></filter></defs><g id="g31e21232314_0_33.3"><path fill="#fdb515" d="m41.0477 27.293962l0 55.30709l-27.653542 -55.30709z" fill-rule="evenodd"/></g><g filter="url(#shadowFilter-g31e21232314_0_33.4)"><use xlink:href="#g31e21232314_0_33.4" transform="matrix(1.0 0.0 0.0 1.0 0.0 2.0)"/></g><defs><filter id="shadowFilter-g31e21232314_0_33.4" filterUnits="userSpaceOnUse"><feGaussianBlur in="SourceAlpha" stdDeviation="2.0" result="blur"/><feComponentTransfer in="blur" color-interpolation-filters="sRGB"><feFuncR type="linear" slope="0" intercept="0.0"/><feFuncG type="linear" slope="0" intercept="0.0"/><feFuncB type="linear" slope="0" intercept="0.0"/><feFuncA type="linear" slope="0.0" intercept="0"/></feComponentTransfer></filter></defs><g id="g31e21232314_0_33.4"><path fill="#30a2ff" d="m41.046566 82.60105l21.72966 0l18.653545 -70.385826l-25.574806 13.461943z" fill-rule="evenodd"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View file

@ -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<string, "svg" | "png" | "jpg"> = {
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 (
<span title={title} aria-hidden={true} className="inline-flex shrink-0">
<HugeiconsIcon
icon={DashboardSquare01Icon}
className={cn("shrink-0", className)}
/>
</span>
);
}
if (!src) return null;
return (
<img
src={src}
alt=""
title={title}
aria-hidden={true}
className={cn(
"shrink-0 object-contain",
providerType === "openai" && "dark:invert",
className,
)}
/>
);
}
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 && (
<span className="size-2 shrink-0 rounded-full bg-emerald-500" />
)}
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 flex-1 truncate font-heading text-[16px] font-medium leading-tight text-black dark:text-white">
{currentModel?.icon ? (
<span className="flex shrink-0 items-center">{currentModel.icon}</span>
) : null}
<span className="flex min-w-0 flex-1 items-baseline">
<span className="min-w-0 flex flex-1 items-baseline truncate font-heading text-[16px] font-medium leading-tight text-black dark:text-white">
{currentModel?.name ?? "Select model"}
{showCloudIndicator ? (
<HugeiconsIcon
icon={CloudIcon}
strokeWidth={1.75}
className="relative top-[0.15625rem] ml-1.5 mr-[0.36rem] size-3.5 shrink-0 text-muted-foreground"
/>
) : null}
</span>
{currentModel?.description && (
<span className="shrink-0 text-xs leading-none text-muted-foreground">
<span
className={cn(
"shrink-0 text-xs leading-none text-muted-foreground",
showCloudIndicator ? "" : "ml-2",
)}
>
{currentModel.description}
</span>
)}
@ -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 (
<PopoverContent
@ -150,12 +251,32 @@ function ModelSelectorContent({
)}
>
{chatOnly ? (
<HubModelPicker models={models} value={value} onSelect={onSelect} onFoldersChange={onFoldersChange} />
hasExternal ? (
<Tabs defaultValue={chatOnlyTabsDefault} className="w-full">
<TabsList className="mb-2 w-full">
<TabsTrigger value="hub">Hub models</TabsTrigger>
<TabsTrigger value="external">External</TabsTrigger>
</TabsList>
<TabsContent value="hub" className="m-0">
<HubModelPicker models={models} value={value} onSelect={onSelect} onFoldersChange={onFoldersChange} />
</TabsContent>
<TabsContent value="external" className="m-0">
<ExternalModelPicker
externalModels={externalModels}
value={value}
onSelect={onSelect}
/>
</TabsContent>
</Tabs>
) : (
<HubModelPicker models={models} value={value} onSelect={onSelect} onFoldersChange={onFoldersChange} />
)
) : (
<Tabs defaultValue="hub" className="w-full">
<Tabs defaultValue={studioTabsDefault} className="w-full">
<TabsList className="mb-2 w-full">
<TabsTrigger value="hub">Hub models</TabsTrigger>
<TabsTrigger value="lora">Fine-tuned</TabsTrigger>
{hasExternal ? <TabsTrigger value="external">External</TabsTrigger> : null}
</TabsList>
<TabsContent value="hub" className="m-0">
@ -171,6 +292,16 @@ function ModelSelectorContent({
deleteDisabled={deleteDisabled}
/>
</TabsContent>
{hasExternal ? (
<TabsContent value="external" className="m-0">
<ExternalModelPicker
externalModels={externalModels}
value={value}
onSelect={onSelect}
/>
</TabsContent>
) : null}
</Tabs>
)}
@ -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: (
<ExternalProviderLogo
providerType={externalModel.providerType}
className="size-4"
title={externalModel.providerName}
/>
),
});
}
return all;
}, [loraModels, models]);
}, [externalModels, loraModels, models]);
const currentModel = useMemo(() => {
if (!selected) return undefined;
@ -303,6 +449,7 @@ export function ModelSelector({
<ModelSelectorTrigger
currentModel={currentModel}
isLoaded={isLoaded}
showCloudIndicator={showCloudIndicator}
variant={variant}
size={size}
className={className}
@ -311,6 +458,7 @@ export function ModelSelector({
<ModelSelectorContent
models={models}
loraModels={loraModels}
externalModels={externalModels}
value={selected}
onSelect={handleSelect}
onEject={onEject ? handleEject : undefined}
@ -327,3 +475,105 @@ export function ModelSelector({
ModelSelector.Trigger = ModelSelectorTrigger;
ModelSelector.Content = ModelSelectorContent;
function normalizeForSearch(value: string): string {
return value.toLowerCase().replace(/[\s_.-]/g, "");
}
function ExternalModelPicker({
externalModels,
value,
onSelect,
}: {
externalModels: ExternalModelOption[];
value?: string;
onSelect: (id: string, meta: ModelSelectorChangeMeta) => 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 (
<div className="space-y-2">
<div className="relative">
<HugeiconsIcon
icon={Search01Icon}
className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground"
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search external models"
className="h-9 pl-8"
/>
</div>
<div className="max-h-64 overflow-y-auto">
<div className="space-y-2 p-1">
{grouped.length === 0 ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
No external models configured.
</div>
) : (
grouped.map((group) => (
<div key={group.providerId}>
<div className="flex items-center gap-2 px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<ExternalProviderLogo
providerType={group.models[0]?.providerType}
className="size-3.5"
title={group.providerName}
/>
<span className="min-w-0 truncate">{group.providerName}</span>
</div>
{group.models.map((model) => (
<button
key={model.id}
type="button"
onClick={() =>
onSelect(model.id, {
source: "external",
isLora: false,
})
}
className={cn(
"flex w-full items-center rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent",
value === model.id && "bg-accent/60",
)}
>
<span className="min-w-0 truncate">{model.name}</span>
</button>
))}
</div>
))
)}
</div>
</div>
</div>
);
}

View file

@ -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;

View file

@ -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}
>
<HugeiconsIcon
icon={Idea01Icon}
className="aui-reasoning-trigger-icon size-4 shrink-0"
/>
<LightbulbIcon className="aui-reasoning-trigger-icon size-4 shrink-0" />
<span
data-slot="reasoning-trigger-label"
className="aui-reasoning-trigger-label-wrapper relative inline-block leading-none"

View file

@ -13,6 +13,7 @@ import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";
import { Sources, SourcesGroup } from "@/components/assistant-ui/sources";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { ToolGroup } from "@/components/assistant-ui/tool-group";
import { CodeExecutionToolUI } from "@/components/assistant-ui/tool-ui-code-execution";
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
@ -31,6 +32,9 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { sentAudioNames } from "@/features/chat/api/chat-adapter";
import { parseExternalModelId } from "@/features/chat/external-providers";
import { getExternalReasoningCapabilities } from "@/features/chat/provider-capabilities";
import { useExternalProvidersStore } from "@/features/chat/stores/external-providers-store";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params";
import { isTauri } from "@/lib/api-base";
@ -210,13 +214,28 @@ const ThreadScrollToBottom: FC = () => {
};
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 (
<div className="aui-thread-welcome-root mx-auto my-auto flex w-full max-w-(--thread-max-width) grow flex-col">
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center pb-[48px]">
<div className="aui-thread-welcome-message flex w-full flex-col justify-center gap-6 px-4">
<div className="flex flex-col items-center gap-2 text-center">
<img
src="/Sloth emojis/sloth pc square.png"
src={currentEmojiSrc}
alt="Sloth mascot"
className="size-20"
/>
@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
@ -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}`}
>
<LightbulbIcon className="size-3.5" />
{effectiveReasoningVisualEnabled ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>
Think:{" "}
{reasoningEffort.charAt(0).toUpperCase() +
reasoningEffort.slice(1)}
Think: {effectiveReasoningVisualEnabled ? effortLabel : "None"}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(["low", "medium", "high"] as const).map((level) => (
{effectiveSupportsReasoningOff && (
<DropdownMenuItem
onSelect={() => {
setReasoningEnabled(false);
applyQwenThinkingParams(false);
}}
>
None
{!effectiveReasoningVisualEnabled ? " \u2713" : ""}
</DropdownMenuItem>
)}
{effectiveReasoningEffortLevels
.filter((level) => level !== "none")
.map((level) => (
<DropdownMenuItem
key={level}
onSelect={() => 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" : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@ -508,17 +614,39 @@ const ReasoningToggle: FC = () => {
return (
<button
type="button"
disabled={disabled}
disabled={disabled || reasoningLockedOn}
aria-disabled={disabled || reasoningLockedOn}
title={
reasoningLockedOn
? "This model requires reasoning to stay on."
: undefined
}
onClick={() => {
if (reasoningLockedOn) return;
const next = !reasoningEnabled;
setReasoningEnabled(next);
applyQwenThinkingParams(next);
// Mutual exclusion with the Search pill on Kimi — see the
// dropdown branch above and shared-composer for the same rule.
if (isKimiExternal && next && toolsEnabled) {
setToolsEnabled(false);
}
}}
className="composer-pill-btn"
data-active={reasoningEnabled && !disabled ? "true" : "false"}
aria-label={reasoningEnabled ? "Disable thinking" : "Enable thinking"}
data-active={
reasoningLockedOn || (effectiveReasoningEnabled && !disabled)
? "true"
: "false"
}
aria-label={
reasoningLockedOn
? "Thinking is required for this model"
: effectiveReasoningEnabled
? "Disable thinking"
: "Enable thinking"
}
>
{reasoningEnabled && !disabled ? (
{reasoningLockedOn || (effectiveReasoningEnabled && !disabled) ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
@ -570,16 +698,44 @@ const WebSearchToggle: FC = () => {
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
);
const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
// External providers (OpenAI today) expose a server-side web_search tool
// even when the local tool runtime is unavailable — gate the Search pill
// on either source so it lights up on external models too. Mirror of
// shared-composer's searchDisabled.
const supportsBuiltinWebSearch = useChatRuntimeStore(
(s) => s.supportsBuiltinWebSearch,
);
const toolsEnabled = useChatRuntimeStore((s) => s.toolsEnabled);
const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled);
const disabled = !(modelLoaded && supportsTools);
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
const externalProviders = useExternalProvidersStore((s) => s.providers);
const externalSelection = parseExternalModelId(checkpoint);
const selectedExternalProvider =
externalSelection != null
? externalProviders.find((p) => p.id === externalSelection.providerId)
: undefined;
const isKimiExternal = selectedExternalProvider?.providerType === "kimi";
const disabled =
!modelLoaded || !(supportsTools || supportsBuiltinWebSearch);
return (
<button
type="button"
disabled={disabled}
onClick={() => setToolsEnabled(!toolsEnabled)}
onClick={() => {
const next = !toolsEnabled;
setToolsEnabled(next);
// Kimi's $web_search builtin requires thinking=disabled (see
// https://platform.kimi.ai/docs/guide/use-web-search). Keep
// the two pills mutually exclusive so the visible state always
// matches what the backend ends up sending.
if (isKimiExternal) {
setReasoningEnabled(!next);
applyQwenThinkingParams(!next);
}
}}
className="composer-pill-btn"
data-active={toolsEnabled && !disabled ? "true" : "false"}
aria-label={toolsEnabled ? "Disable web search" : "Enable web search"}
@ -595,9 +751,18 @@ const CodeToolsToggle: FC = () => {
(s) => !!s.params.checkpoint && !s.modelLoading,
);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
// External providers have no local tool runtime, but Anthropic's
// Claude 4.x dispatches code_execution_20250825 server-side. The
// chat-page resolver stashes that capability in the runtime store
// (next to supportsBuiltinWebSearch). Mirror of shared-composer's
// codeDisabled so this pill lights up in active threads too.
const supportsBuiltinCodeExecution = useChatRuntimeStore(
(s) => s.supportsBuiltinCodeExecution,
);
const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled);
const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled);
const disabled = !(modelLoaded && supportsTools);
const disabled =
!modelLoaded || !(supportsTools || supportsBuiltinCodeExecution);
return (
<button
@ -791,6 +956,7 @@ const AssistantMessage: FC = () => {
web_search: WebSearchToolUI,
python: PythonToolUI,
terminal: TerminalToolUI,
code_execution: CodeExecutionToolUI,
},
Fallback: ToolFallback,
},

View file

@ -0,0 +1,126 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import { type ToolCallMessagePartComponent, useAuiState } from "@assistant-ui/react";
import { FileTextIcon, LoaderIcon, TerminalIcon } from "lucide-react";
import { memo, useEffect, useState } from "react";
import {
ToolFallbackContent,
ToolFallbackRoot,
ToolFallbackTrigger,
} from "./tool-fallback";
/**
* Renders the synthetic `_toolEvent` chunks emitted by
* `_stream_anthropic` when Anthropic's `code_execution_20250825` tool
* fires. The backend collapses Anthropic's two sub-tools
* (`bash_code_execution`, `text_editor_code_execution`) into a single
* `tool_name: "code_execution"`, with `arguments.kind` ("bash" or
* "text_editor") and a per-kind argument shape:
*
* kind=bash: { command: "<shell command>" }
* kind=text_editor: { command: "view"|"create"|"str_replace", path, ... }
*
* The `result` payload is preformatted text:
* - bash: stdout, then "--- stderr ---" block + return_code if non-zero
* - text_editor view: file contents verbatim
* - text_editor create: "Created <path>" / "Updated <path>"
* - text_editor str_replace: unified-diff `lines` joined with "\n"
* - error: "Error: <error_code>"
*/
interface CodeExecutionArgs {
kind?: "bash" | "text_editor";
command?: string;
path?: string;
}
const CodeExecutionToolUIImpl: ToolCallMessagePartComponent = ({
args,
result,
status,
}) => {
const parsedArgs = (args as CodeExecutionArgs) ?? {};
const kind = parsedArgs.kind ?? "bash";
const command = parsedArgs.command ?? "";
const path = parsedArgs.path ?? "";
const isRunning = status?.type === "running";
let runningLabel: string;
let completedLabel: string;
let Icon = TerminalIcon;
if (kind === "text_editor") {
Icon = FileTextIcon;
if (command === "view") {
runningLabel = path ? `Viewing ${path}` : "Viewing file…";
completedLabel = path ? `Viewed ${path}` : "Viewed file";
} else if (command === "create") {
runningLabel = path ? `Writing ${path}` : "Writing file…";
completedLabel = path ? `Wrote ${path}` : "Wrote file";
} else if (command === "str_replace") {
runningLabel = path ? `Editing ${path}` : "Editing file…";
completedLabel = path ? `Edited ${path}` : "Edited file";
} else {
runningLabel = "Running file operation…";
completedLabel = "File operation";
}
} else {
runningLabel = "Running command…";
completedLabel = command ? `Ran \`${command}\`` : "Ran command";
}
// Collapse the card once the model has resumed streaming prose after
// the tool call. Mirrors WebSearchToolUI's behavior so the tool-card
// doesn't crowd the final answer once the run is done.
const hasText = useAuiState(({ message }) =>
message.content.some(
(p) =>
p.type === "text" &&
"text" in p &&
(p as { text: string }).text.length > 0,
),
);
const [open, setOpen] = useState(isRunning);
useEffect(() => {
if (isRunning) {
setOpen(true);
} else if (hasText) {
setOpen(false);
}
}, [isRunning, hasText]);
const resultText =
typeof result === "string"
? result
: result != null
? JSON.stringify(result, null, 2)
: "";
return (
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
<ToolFallbackTrigger
toolName={isRunning ? runningLabel : completedLabel}
status={status}
icon={Icon}
/>
<ToolFallbackContent>
{isRunning ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
<span>{runningLabel}</span>
</div>
) : resultText ? (
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
{resultText}
</pre>
) : null}
</ToolFallbackContent>
</ToolFallbackRoot>
);
};
export const CodeExecutionToolUI = memo(
CodeExecutionToolUIImpl,
) as unknown as ToolCallMessagePartComponent;
CodeExecutionToolUI.displayName = "CodeExecutionToolUI";

View file

@ -0,0 +1,72 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { cn } from "@/lib/utils";
import { DashboardSquare01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { isCustomProviderType } from "./external-providers";
/**
* Registry logos live at `public/provider-logos/{provider_type}.{ext}` where `provider_type`
* matches `PROVIDER_REGISTRY` keys exactly (lowercase). Extension varies by asset (svg preferred).
*/
const PROVIDER_LOGO_EXT: Record<string, "svg" | "png" | "jpg"> = {
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",
};
export function apiProviderLogoSrc(
providerType: string | undefined | null,
): 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}`;
}
interface ApiProviderLogoProps {
providerType: string | undefined | null;
className?: string;
title?: string;
}
/**
* Renders the logo for a registry provider type when `provider_type.{ext}` exists under
* `public/provider-logos/`.
* OpenAI's asset is black-on-transparent; it is inverted in dark mode for contrast.
*/
export function ApiProviderLogo({ providerType, className, title }: ApiProviderLogoProps) {
const src = apiProviderLogoSrc(providerType);
if (!src && isCustomProviderType(providerType)) {
return (
<span title={title} aria-hidden className="inline-flex shrink-0">
<HugeiconsIcon icon={DashboardSquare01Icon} className={cn("shrink-0", className)} />
</span>
);
}
if (!src) return null;
return (
<img
src={src}
alt=""
title={title}
aria-hidden
className={cn(
"shrink-0 object-contain",
providerType === "openai" && "dark:invert",
className,
)}
/>
);
}

View file

@ -15,7 +15,34 @@ import {
streamChatCompletions,
validateModel,
} from "./chat-api";
import { pickFriendlyContainerName } from "../lib/friendly-names";
import { createOpenAIContainer } from "./openai-containers";
import {
encryptProviderApiKey,
isProviderKeyRotationError,
} from "./providers-api";
import { db } from "../db";
import type {
OpenAIChatCompletionsRequest,
OpenAIMessageContent,
} from "../types/api";
import {
getExternalProviderApiKey,
isCustomProviderType,
loadExternalProviders,
parseExternalModelId,
supportsProviderPromptCaching,
toExternalBackendProviderType,
} from "../external-providers";
import {
EXTERNAL_MAX_OUTPUT_TOKENS,
clampReasoningEffortToLevels,
getExternalMinOutputTokens,
getExternalReasoningCapabilities,
getProviderCapabilities,
providerSupportsBuiltinCodeExecution,
providerSupportsBuiltinWebSearch,
} from "../provider-capabilities";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import { isMultimodalResponse } from "../types/api";
import type { ChatModelSummary } from "../types/runtime";
@ -118,6 +145,70 @@ function estimateTokenCount(text: string): number | undefined {
return Math.max(1, Math.round(trimmed.length / 4));
}
/**
* Normalize a streamed `delta.content` to a plain text string.
*
* OpenAI Chat Completions originally typed `delta.content` as a string, but
* a number of providers now emit it as an array of structured content parts.
* Concatenating that with `cumulativeText += delta` would stringify each
* part as `[object Object]` this function is the guard against that.
*
* Handled part shapes:
* { type: "text" | "output_text", text | content: "..." } text body
* { type: "thinking" | "reasoning", thinking | text: "..." } wrapped as
* inline `<think>...</think>` so the downstream parser
* (`parseAssistantContent`) lifts it into a reasoning part the same way
* it does for providers that emit thinking inline. Without this wrap,
* Mistral magistral and similar reasoning-part providers would lose
* their thinking panel.
*
* Unknown part types are skipped better to drop a stray field than to
* stringify an object and pollute the rendered chat with `[object Object]`.
*/
function extractDeltaText(delta: unknown): string {
const extractReasoningText = (payload: unknown): string => {
if (typeof payload === "string") return payload;
if (Array.isArray(payload)) {
return payload.map((item) => extractReasoningText(item)).join("");
}
if (!payload || typeof payload !== "object") return "";
const obj = payload as Record<string, unknown>;
for (const key of ["thinking", "text", "content", "reasoning", "summary"]) {
if (key in obj) {
const text = extractReasoningText(obj[key]);
if (text) return text;
}
}
return "";
};
if (typeof delta === "string") return delta;
if (!Array.isArray(delta)) return "";
let out = "";
for (const part of delta) {
if (typeof part === "string") {
out += part;
continue;
}
if (!part || typeof part !== "object") continue;
const obj = part as {
type?: string;
text?: string;
content?: string;
thinking?: string;
};
if (obj.type === "text" || obj.type === "output_text") {
if (typeof obj.text === "string") out += obj.text;
else if (typeof obj.content === "string") out += obj.content;
} else if (obj.type === "thinking" || obj.type === "reasoning") {
const thinking = extractReasoningText(obj);
if (thinking) out += `<think>${thinking}</think>`;
}
}
return out;
}
function buildTiming(
streamStartTime: number,
totalChunks: number,
@ -162,9 +253,51 @@ function collectTextParts(message: RunMessage): string[] {
return textParts;
}
function collectImageParts(
message: RunMessage,
): Array<{ type: "image_url"; image_url: { url: string } }> {
const parts: Array<{ type: "image_url"; image_url: { url: string } }> = [];
for (const part of message.content ?? []) {
if (part.type === "image" && "image" in part) {
const src = (part as { image: string }).image;
if (src) {
parts.push({
type: "image_url",
image_url: {
url: src.startsWith("data:") ? src : `data:image/png;base64,${src}`,
},
});
}
}
}
if ("attachments" in message && (message.attachments?.length ?? 0) > 0) {
for (const attachment of message.attachments ?? []) {
for (const part of attachment.content ?? []) {
if (part.type === "image" && "image" in part) {
const src = (part as { image: string }).image;
if (src) {
parts.push({
type: "image_url",
image_url: {
url: src.startsWith("data:")
? src
: `data:image/png;base64,${src}`,
},
});
}
}
}
}
}
return parts;
}
function toOpenAIMessage(message: RunMessage): {
role: "system" | "user" | "assistant";
content: string;
content: OpenAIMessageContent;
} | null {
if (
message.role !== "system" &&
@ -174,17 +307,25 @@ function toOpenAIMessage(message: RunMessage): {
return null;
}
let content = collectTextParts(message).join("\n");
let textContent = collectTextParts(message).join("\n");
// Strip inline audio base64 from prior assistant messages to avoid
// inflating token counts (e.g. audio-player responses with embedded WAV).
if (message.role === "assistant") {
content = content.replace(
textContent = textContent.replace(
/data:audio\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g,
"[audio]",
);
}
return { role: message.role, content };
const imageParts = collectImageParts(message);
if (imageParts.length > 0) {
return {
role: message.role,
content: [{ type: "text", text: textContent }, ...imageParts],
};
}
return { role: message.role, content: textContent };
}
function extractImageBase64(input: string): string | undefined {
@ -594,6 +735,33 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
toolsEnabled,
codeToolsEnabled,
} = runtime;
const externalSelection = parseExternalModelId(params.checkpoint);
const isExternalRequest = externalSelection !== null;
const externalProvider = isExternalRequest
? loadExternalProviders().find(
(provider) => provider.id === externalSelection.providerId,
)
: null;
const externalApiKey = externalProvider
? getExternalProviderApiKey(externalProvider.id).trim()
: "";
if (isExternalRequest && !externalProvider) {
toast.error("External provider not found.", {
description: "Open Connections and re-add this provider.",
});
throw new Error("External provider not found.");
}
// Local providers (llama.cpp / vLLM / Ollama) allow an empty key — only block hosted providers.
const externalProviderIsCustom = externalProvider
? isCustomProviderType(externalProvider.providerType)
: false;
if (isExternalRequest && !externalApiKey && !externalProviderIsCustom) {
toast.error("Missing API key for selected external provider.", {
description: "Open Connections and set the API key again.",
});
throw new Error("Missing external provider API key.");
}
const outboundMessages = messages
.map(toOpenAIMessage)
@ -711,6 +879,17 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
let cumulativeText = "";
let reasoningStartAt: number | null = null;
let reasoningDuration = 0;
// Tracks whether we are currently inside a `<think>` block opened by
// a `delta.reasoning_content` chunk. Kimi (kimi-k2.6, kimi-k2-thinking)
// and DeepSeek's reasoner stream their thinking as a separate
// `reasoning_content` field on the chat-completion delta — not as
// `content`, not as a structured part. We wrap those chunks with
// inline `<think>...</think>` so the existing parseAssistantContent
// lifts them into the reasoning panel the same way it does for
// local Harmony models. State has to live outside the SSE loop
// because the close tag fires when the next chunk carries content
// (or when the stream ends).
let reasoningContentOpen = false;
// Tool call content parts — accumulated and yielded cumulatively.
// result is set directly on the tool-call part when tool_end arrives.
const toolCallParts: ToolCallMessagePart[] = [];
@ -760,8 +939,260 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
supportsPreserveThinking,
preserveThinking,
} = runtime;
const stream = streamChatCompletions(
{
const externalBackendProviderType = toExternalBackendProviderType(
externalProvider?.providerType,
);
const externalCapabilities = getProviderCapabilities(
externalProvider?.providerType,
);
const externalReasoningCaps: ReturnType<
typeof getExternalReasoningCapabilities
> =
externalSelection && externalProvider
? getExternalReasoningCapabilities(
externalProvider.providerType,
externalSelection.modelId,
{
isReasoningProvider:
externalProvider.isReasoningModel === true,
},
)
: {
supportsReasoning,
reasoningStyle,
reasoningAlwaysOn: false,
supportsReasoningOff: false,
reasoningEffortLevels: ["low", "medium", "high"] as const,
};
type RequestReasoningEffort = Extract<
NonNullable<OpenAIChatCompletionsRequest["reasoning_effort"]>,
"none" | "minimal" | "low" | "medium" | "high" | "max" | "xhigh"
>;
const fallbackExternalEffort =
(externalReasoningCaps.reasoningEffortLevels[0] ??
"low") as RequestReasoningEffort;
const selectedExternalEffort: RequestReasoningEffort =
clampReasoningEffortToLevels(
reasoningEffort,
externalReasoningCaps.reasoningEffortLevels,
) as RequestReasoningEffort;
const localReasoningEffort =
reasoningEffort === "low" || reasoningEffort === "medium" || reasoningEffort === "high"
? reasoningEffort
: "low";
const externalReasoningEnabled =
!externalReasoningCaps.supportsReasoningOff ? true : reasoningEnabled;
const buildRequestPayload = async (
forceRefreshPublicKey = false,
): Promise<OpenAIChatCompletionsRequest> => {
if (externalSelection && externalProvider) {
// OpenAI shell-tool container reuse: pull the per-thread
// container_id (if any) so subsequent turns in the same
// thread reference the existing container instead of
// auto-creating a fresh one. Empty string / undefined →
// backend falls back to container_auto. Anthropic doesn't
// use this (server-side per-turn container).
let openaiCodeExecContainerId: string | null = null;
const codeExecEnabledForThisTurn =
codeToolsEnabled &&
providerSupportsBuiltinCodeExecution(
externalProvider.providerType,
externalSelection.modelId,
externalProvider.baseUrl,
);
if (codeExecEnabledForThisTurn && resolvedThreadId) {
try {
const thread = await db.threads.get(resolvedThreadId);
openaiCodeExecContainerId =
thread?.openaiCodeExecContainerId ?? null;
} catch {
openaiCodeExecContainerId = null;
}
// Cross-thread inheritance: when the active thread has
// no container yet, default to the one most recently
// used on *any* other thread (provider-scoped).
// Matches what the Code Execution settings section
// shows in the picker, and keeps the user from getting
// a fresh container on every new thread. The picker
// can still be set to "Auto-create per thread"
// explicitly to opt into a fresh container — but
// that's done via the dropdown, not silently.
if (
!openaiCodeExecContainerId &&
externalProvider.providerType === "openai"
) {
try {
const others = await db.threads
.orderBy("createdAt")
.reverse()
.toArray();
for (const t of others) {
if (t.id === resolvedThreadId) continue;
if (t.openaiCodeExecContainerId) {
openaiCodeExecContainerId = t.openaiCodeExecContainerId;
void db.threads
.update(resolvedThreadId, {
openaiCodeExecContainerId,
})
.catch(() => {});
break;
}
}
} catch {
/* fall through to lazy-create below */
}
}
// Lazy pre-create when there's no inherited container.
// We always POST /v1/containers ourselves (rather than
// letting the backend send container_auto) so every
// container shows up in the picker with a friendly
// English-word name and the user's configured TTL.
// Falls back to container_auto only if the POST fails
// — keeps the chat moving in that case.
if (
!openaiCodeExecContainerId &&
externalProvider.providerType === "openai"
) {
const ttl = externalProvider.openaiContainerTtlMinutes;
const ttlToUse =
typeof ttl === "number" && ttl >= 1 ? ttl : 20;
try {
const created = await createOpenAIContainer(
{
apiKey: externalApiKey,
baseUrl: externalProvider.baseUrl || null,
},
{
// Friendly English-word name so the container
// is human-readable in the picker list (e.g.
// "kestrel-3f9c") instead of a thread-id slug
// or OpenAI's default blank name.
name: pickFriendlyContainerName(),
ttlMinutes: ttlToUse,
},
);
openaiCodeExecContainerId = created.id;
void db.threads
.update(resolvedThreadId, {
openaiCodeExecContainerId: created.id,
})
.catch(() => {});
} catch {
// Fall back to backend's container_auto path on
// failure — keeps the chat moving; the next turn
// can retry. The auto-created container will be
// unnamed, but the chat doesn't break.
openaiCodeExecContainerId = null;
}
}
}
return {
model: externalSelection.modelId,
messages: outboundMessages,
stream: true,
// Reasoning-class models (OpenAI gpt-5.x / o3) reject temperature
// and top_p; only forward when the active provider supports them.
...(externalCapabilities?.temperature !== false
? { temperature: params.temperature }
: {}),
...(externalCapabilities?.topP !== false
? { top_p: params.topP }
: {}),
// Clamp to the cross-provider output cap so a maxTokens value
// carried over from a local-model session does not blow past
// provider limits (e.g. Claude Opus 400s on >128k). Also
// floor to the provider's documented minimum — Kimi's
// thinking models need >=16k or the response truncates
// before the answer fits alongside reasoning_content.
max_tokens: Math.min(
Math.max(
params.maxTokens,
getExternalMinOutputTokens(externalProvider?.providerType),
),
EXTERNAL_MAX_OUTPUT_TOKENS,
),
// Only forward sampling knobs the provider actually accepts; the
// backend's external-provider proxy is param-permissive and would
// surface a 400 from providers that reject unknown fields (e.g.
// OpenAI rejects top_k, Anthropic/DeepSeek reject presence_penalty).
...(externalCapabilities?.topK ? { top_k: params.topK } : {}),
...(externalCapabilities?.presencePenalty
? { presence_penalty: params.presencePenalty }
: {}),
// Built-in tools: Search pill maps to provider-side
// web_search (currently OpenAI / Anthropic / OpenRouter /
// Kimi); Code pill maps to Anthropic's server-side
// code_execution_20250825 tool (Anthropic is the only
// external provider that ships one today). Backend
// translates enabled_tools into each provider's tool
// schema — for Anthropic that's the entries appended to
// body["tools"] inside _stream_anthropic.
...((toolsEnabled &&
providerSupportsBuiltinWebSearch(externalProvider.providerType)) ||
(codeToolsEnabled &&
providerSupportsBuiltinCodeExecution(
externalProvider.providerType,
externalSelection.modelId,
externalProvider.baseUrl,
))
? {
enable_tools: true,
enabled_tools: [
...(toolsEnabled &&
providerSupportsBuiltinWebSearch(
externalProvider.providerType,
)
? ["web_search"]
: []),
...(codeToolsEnabled &&
providerSupportsBuiltinCodeExecution(
externalProvider.providerType,
externalSelection.modelId,
externalProvider.baseUrl,
)
? ["code_execution"]
: []),
],
}
: {}),
provider_id: externalProvider.id,
provider_type: externalBackendProviderType,
external_model: externalSelection.modelId,
...(externalApiKey
? {
encrypted_api_key: await encryptProviderApiKey(
externalApiKey,
forceRefreshPublicKey,
),
}
: {}),
provider_base_url: externalProvider.baseUrl || null,
...(openaiCodeExecContainerId
? {
openai_code_exec_container_id: openaiCodeExecContainerId,
}
: {}),
...(supportsProviderPromptCaching(externalProvider.providerType)
? {
enable_prompt_caching:
externalProvider.enablePromptCaching ?? true,
}
: {}),
...(externalReasoningCaps.supportsReasoning
? externalReasoningCaps.reasoningStyle === "reasoning_effort"
? externalReasoningEnabled
? { reasoning_effort: selectedExternalEffort }
: externalReasoningCaps.supportsReasoningOff
? { reasoning_effort: "none" }
: {
reasoning_effort: fallbackExternalEffort,
}
: { enable_thinking: reasoningEnabled }
: {}),
};
}
return {
model: params.checkpoint,
messages: outboundMessages,
stream: true,
@ -779,7 +1210,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
...(useAdapter === undefined ? {} : { use_adapter: useAdapter }),
...(supportsReasoning
? reasoningStyle === "reasoning_effort"
? { reasoning_effort: reasoningEffort }
? reasoningEnabled
? { reasoning_effort: localReasoningEffort }
: {}
: { enable_thinking: reasoningEnabled }
: {}),
...(supportsPreserveThinking ? { preserve_thinking: preserveThinking } : {}),
@ -798,116 +1231,262 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
})(),
}
: {}),
},
abortSignal,
);
};
};
for await (const chunk of stream) {
// Handle tool status events
const toolStatusText = (chunk as unknown as { _toolStatus?: string })._toolStatus;
if (toolStatusText !== undefined) {
runtime.setToolStatus(toolStatusText || null);
continue;
}
let retriedWithRefreshedKey = false;
while (true) {
try {
const stream = streamChatCompletions(
await buildRequestPayload(retriedWithRefreshedKey),
abortSignal,
);
// Emit tool-call content parts for assistant-ui.
// On tool_start: add a new tool-call part (renders in "running" state).
// On tool_end: set result on the existing part (transitions to "complete").
const toolEvent = (chunk as unknown as { _toolEvent?: Record<string, unknown> })._toolEvent;
if (toolEvent !== undefined) {
if (toolEvent.type === "tool_start") {
const id = (toolEvent.tool_call_id as string) || `${toolEvent.tool_name}_${Date.now()}`;
const toolArgs = (toolEvent.arguments ?? {}) as ToolCallMessagePart["args"];
toolCallParts.push({
type: "tool-call" as const,
toolCallId: id,
toolName: toolEvent.tool_name as string,
argsText: JSON.stringify(toolArgs),
args: toolArgs,
});
} else if (toolEvent.type === "tool_end") {
const id = (toolEvent.tool_call_id as string) ||
toolCallParts[toolCallParts.length - 1]?.toolCallId || "";
const idx = toolCallParts.findIndex((p) => p.toolCallId === id);
if (idx !== -1) {
const rawResult = (toolEvent.result as string) ?? "";
const imgMarker = "\n__IMAGES__:";
const imgIdx = rawResult.lastIndexOf(imgMarker);
let parsedResult: string | { text: string; images: string[]; sessionId: string };
if (imgIdx !== -1) {
const text = rawResult.slice(0, imgIdx);
// Fall back to "_default" to match the backend sandbox directory
// used when no session_id is provided (see tools.py _get_workdir).
const sessionId = resolvedThreadId || "_default";
try {
const images = JSON.parse(rawResult.slice(imgIdx + imgMarker.length)) as string[];
parsedResult = { text, images, sessionId };
} catch {
parsedResult = rawResult;
for await (const chunk of stream) {
// Handle tool status events
const toolStatusText = (chunk as unknown as { _toolStatus?: string })._toolStatus;
if (toolStatusText !== undefined) {
runtime.setToolStatus(toolStatusText || null);
continue;
}
// Emit tool-call content parts for assistant-ui.
// On tool_start: add a new tool-call part (renders in "running" state).
// On tool_end: set result on the existing part (transitions to "complete").
const toolEvent = (chunk as unknown as { _toolEvent?: Record<string, unknown> })._toolEvent;
if (toolEvent !== undefined) {
// OpenAI shell-tool container persistence — see
// ThreadRecord.openaiCodeExecContainerId. The backend
// emits these synthetic events on the OpenAI Responses
// SSE stream after capturing the container_id from a
// response, or detecting an expired-container error.
if (toolEvent.type === "container_ready") {
const newContainerId = toolEvent.container_id as
| string
| undefined;
if (newContainerId && resolvedThreadId) {
void db.threads
.update(resolvedThreadId, {
openaiCodeExecContainerId: newContainerId,
})
.catch(() => {});
}
} else {
parsedResult = rawResult;
continue;
}
toolCallParts[idx] = { ...toolCallParts[idx], result: parsedResult };
if (toolEvent.type === "container_invalidated") {
if (resolvedThreadId) {
void db.threads
.update(resolvedThreadId, {
openaiCodeExecContainerId: null,
})
.catch(() => {});
}
continue;
}
if (toolEvent.type === "tool_start") {
const id = (toolEvent.tool_call_id as string) || `${toolEvent.tool_name}_${Date.now()}`;
const toolArgs = (toolEvent.arguments ?? {}) as ToolCallMessagePart["args"];
toolCallParts.push({
type: "tool-call" as const,
toolCallId: id,
toolName: toolEvent.tool_name as string,
argsText: JSON.stringify(toolArgs),
args: toolArgs,
});
} else if (toolEvent.type === "tool_end") {
const id = (toolEvent.tool_call_id as string) ||
toolCallParts[toolCallParts.length - 1]?.toolCallId || "";
const idx = toolCallParts.findIndex((p) => p.toolCallId === id);
if (idx !== -1) {
const rawResult = (toolEvent.result as string) ?? "";
const imgMarker = "\n__IMAGES__:";
const imgIdx = rawResult.lastIndexOf(imgMarker);
let parsedResult: string | { text: string; images: string[]; sessionId: string };
if (imgIdx !== -1) {
const text = rawResult.slice(0, imgIdx);
// Fall back to "_default" to match the backend sandbox directory
// used when no session_id is provided (see tools.py _get_workdir).
const sessionId = resolvedThreadId || "_default";
try {
const images = JSON.parse(rawResult.slice(imgIdx + imgMarker.length)) as string[];
parsedResult = { text, images, sessionId };
} catch {
parsedResult = rawResult;
}
} else {
parsedResult = rawResult;
}
toolCallParts[idx] = { ...toolCallParts[idx], result: parsedResult };
}
}
// Yield cumulative state so tool UI updates (tools first, text after)
const textParts = parseAssistantContent(cumulativeText);
yield {
content: [...toolCallParts, ...textParts],
metadata: {
timing: buildTiming(streamStartTime, totalChunks, firstTokenTime),
custom: { reasoningDuration },
},
};
continue;
}
// OpenAI-standard usage chunk: choices=[], usage populated
if (chunk.choices?.length === 0 && chunk.usage) {
serverMetadata = {
usage: chunk.usage,
timings: (chunk as Record<string, unknown>).timings as ServerTimings | undefined,
};
continue;
}
totalChunks += 1;
// OpenRouter's free router (openrouter/free) picks a different
// underlying free model per request and reports it in every
// chunk's top-level `model` field. Latch the first non-empty
// value that differs from the requested checkpoint so the
// header chip can render "openrouter/free:<chosen>".
if (
isExternalRequest &&
externalProvider?.providerType === "openrouter" &&
externalSelection?.modelId === "openrouter/free"
) {
const chunkModel = (chunk as { model?: unknown }).model;
if (
typeof chunkModel === "string" &&
chunkModel.length > 0 &&
chunkModel !== externalSelection.modelId
) {
const storeState = useChatRuntimeStore.getState();
if (storeState.lastOpenRouterChosenModel !== chunkModel) {
storeState.setLastOpenRouterChosenModel(chunkModel);
}
}
}
const rawDelta = chunk.choices?.[0]?.delta?.content;
// Providers like Mistral's magistral return delta.content as an
// array of structured parts; normalize to text (with thinking
// parts re-wrapped as inline <think> tags) so the rest of the
// accumulator stays string-based.
const delta = extractDeltaText(rawDelta);
// Kimi (kimi-k2.6, kimi-k2-thinking) and DeepSeek reasoner
// stream thinking via `delta.reasoning_content` as a plain
// string field — separate from `delta.content` which carries
// the answer. Wrap reasoning chunks inline as <think>...
// </think> so parseAssistantContent treats them like any
// other reasoning. The close tag fires when the next chunk
// brings content, or when the stream ends.
const rawReasoning = (
chunk.choices?.[0]?.delta as
| { reasoning_content?: unknown }
| undefined
)?.reasoning_content;
// OpenRouter uses a third reasoning shape: a structured
// `delta.reasoning_details` array of parts (each carrying
// `text`). The router emits this regardless of which
// underlying provider it picked, so we extract here and
// merge into the same <think>...</think> wrap path used
// for Kimi / DeepSeek reasoning_content. See
// https://openrouter.ai/docs/guides/best-practices/reasoning-tokens
const rawReasoningDetails = (
chunk.choices?.[0]?.delta as
| { reasoning_details?: unknown }
| undefined
)?.reasoning_details;
const reasoningFromDetails = Array.isArray(rawReasoningDetails)
? rawReasoningDetails
.map((part) => {
if (!part || typeof part !== "object") return "";
const text = (part as { text?: unknown }).text;
return typeof text === "string" ? text : "";
})
.join("")
: "";
const reasoning =
(typeof rawReasoning === "string" ? rawReasoning : "") +
reasoningFromDetails;
if (!delta && !reasoning) {
continue;
}
if (waitingFirstChunk) {
waitingFirstChunk = false;
firstTokenTime = Date.now() - streamStartTime;
settleFirstTokenOk();
runtime.setGeneratingStatus(null);
}
if (reasoning) {
if (!reasoningContentOpen) {
cumulativeText += `<think>${reasoning}`;
reasoningContentOpen = true;
} else {
cumulativeText += reasoning;
}
}
if (delta) {
if (reasoningContentOpen) {
cumulativeText += "</think>";
reasoningContentOpen = false;
}
cumulativeText += delta;
}
// Mistral's magistral occasionally emits a trailing
// template-literal artifact (e.g. "${response}") at the end of
// an otherwise complete answer. It is never part of a real
// reply, so strip a trailing `${...}` token from external
// provider streams. The regex anchors to end-of-string and is
// idempotent — fragments mid-stream (e.g. "${re") leave the
// string untouched and only collapse once the closing brace
// arrives. Local-model output is left alone.
if (isExternalRequest) {
cumulativeText = cumulativeText.replace(
/\s*\$\{[^}]*\}\s*$/,
"",
);
}
const parts = parseAssistantContent(cumulativeText);
if (parts.some((part) => part.type === "reasoning") && !reasoningStartAt) {
reasoningStartAt = Date.now();
}
if (hasClosedThinkTag(cumulativeText) && reasoningStartAt && !reasoningDuration) {
reasoningDuration = Math.round((Date.now() - reasoningStartAt) / 1000);
}
if (parts.length > 0 || toolCallParts.length > 0) {
yield {
content: [...toolCallParts, ...parts],
metadata: {
timing: buildTiming(
streamStartTime,
totalChunks,
firstTokenTime,
),
custom: { reasoningDuration },
},
};
}
}
// Yield cumulative state so tool UI updates (tools first, text after)
const textParts = parseAssistantContent(cumulativeText);
yield {
content: [...toolCallParts, ...textParts],
metadata: {
timing: buildTiming(streamStartTime, totalChunks, firstTokenTime),
custom: { reasoningDuration },
},
};
continue;
}
// OpenAI-standard usage chunk: choices=[], usage populated
if (chunk.choices?.length === 0 && chunk.usage) {
serverMetadata = {
usage: chunk.usage,
timings: (chunk as Record<string, unknown>).timings as ServerTimings | undefined,
};
continue;
}
totalChunks += 1;
const delta = chunk.choices?.[0]?.delta?.content;
if (!delta) {
continue;
}
if (waitingFirstChunk) {
waitingFirstChunk = false;
firstTokenTime = Date.now() - streamStartTime;
settleFirstTokenOk();
runtime.setGeneratingStatus(null);
}
cumulativeText += delta;
const parts = parseAssistantContent(cumulativeText);
if (parts.some((part) => part.type === "reasoning") && !reasoningStartAt) {
reasoningStartAt = Date.now();
}
if (hasClosedThinkTag(cumulativeText) && reasoningStartAt && !reasoningDuration) {
reasoningDuration = Math.round((Date.now() - reasoningStartAt) / 1000);
}
if (parts.length > 0 || toolCallParts.length > 0) {
yield {
content: [...toolCallParts, ...parts],
metadata: {
timing: buildTiming(
streamStartTime,
totalChunks,
firstTokenTime,
),
custom: { reasoningDuration },
},
};
break;
} catch (streamError) {
if (
isExternalRequest &&
!retriedWithRefreshedKey &&
isProviderKeyRotationError(streamError)
) {
retriedWithRefreshedKey = true;
continue;
}
throw streamError;
}
}
// If the stream ended while we were still inside a
// delta.reasoning_content block (Kimi / DeepSeek path), close
// the open <think> tag so the reasoning panel parses cleanly.
if (reasoningContentOpen) {
cumulativeText += "</think>";
reasoningContentOpen = false;
}
settleFirstTokenOk();
// Extract source parts from completed web_search tool calls

View file

@ -0,0 +1,124 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* Wrappers for the three OpenAI shell-tool container management
* endpoints exposed by the backend (studio/backend/routes/inference.py).
* Each one proxies to OpenAI's /v1/containers REST surface using the
* user's encrypted API key. Backend rejects any base URL that isn't
* api.openai.com the shell tool only exists on the managed cloud.
*/
import { authFetch } from "@/features/auth";
import { encryptProviderApiKey } from "./providers-api";
export interface OpenAIContainerSummary {
id: string;
name?: string | null;
createdAt?: number | null;
lastActiveAt?: number | null;
expiresAfterMinutes?: number | null;
status?: string | null;
}
interface RawSummary {
id: string;
name?: string | null;
created_at?: number | null;
last_active_at?: number | null;
expires_after_minutes?: number | null;
status?: string | null;
}
function fromRaw(raw: RawSummary): OpenAIContainerSummary {
return {
id: raw.id,
name: raw.name ?? null,
createdAt: raw.created_at ?? null,
lastActiveAt: raw.last_active_at ?? null,
expiresAfterMinutes: raw.expires_after_minutes ?? null,
status: raw.status ?? null,
};
}
async function parseError(response: Response): Promise<string> {
try {
const body = (await response.json()) as { detail?: string };
if (body && typeof body.detail === "string") return body.detail;
} catch {
/* fall through */
}
return `HTTP ${response.status}`;
}
interface AuthInputs {
apiKey: string;
baseUrl: string | null;
}
async function buildAuthBody(auth: AuthInputs) {
return {
encrypted_api_key: await encryptProviderApiKey(auth.apiKey),
provider_base_url: auth.baseUrl,
};
}
export async function listOpenAIContainers(
auth: AuthInputs,
): Promise<OpenAIContainerSummary[]> {
const response = await authFetch(
"/api/inference/external/openai/containers/list",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(await buildAuthBody(auth)),
},
);
if (!response.ok) throw new Error(await parseError(response));
const body = (await response.json()) as { containers?: RawSummary[] };
return (body.containers ?? []).map(fromRaw);
}
export async function createOpenAIContainer(
auth: AuthInputs,
params: { name: string; ttlMinutes: number },
): Promise<OpenAIContainerSummary> {
const response = await authFetch(
"/api/inference/external/openai/containers/create",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
...(await buildAuthBody(auth)),
name: params.name,
ttl_minutes: params.ttlMinutes,
}),
},
);
if (!response.ok) throw new Error(await parseError(response));
const raw = (await response.json()) as RawSummary;
return fromRaw(raw);
}
export async function deleteOpenAIContainer(
auth: AuthInputs,
containerId: string,
): Promise<void> {
const response = await authFetch(
"/api/inference/external/openai/containers/delete",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
...(await buildAuthBody(auth)),
container_id: containerId,
}),
},
);
// 404 = container already gone (deleted elsewhere, or expired-then-purged).
// Treat as idempotent success so a stale list entry doesn't surface as a
// confusing error — the caller will refresh and the entry will disappear.
if (!response.ok && response.status !== 204 && response.status !== 404) {
throw new Error(await parseError(response));
}
}

View file

@ -0,0 +1,234 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import forge from "node-forge";
import { authFetch } from "@/features/auth";
export interface ProviderRegistryEntry {
provider_type: string;
display_name: string;
base_url: string;
default_models: string[];
supports_streaming: boolean;
supports_vision: boolean;
supports_tool_calling: boolean;
/** remote = fetch /models; curated = huge catalogs — UI uses defaults + manual IDs only */
model_list_mode?: "remote" | "curated";
}
export interface ProviderConfig {
id: string;
provider_type: string;
display_name: string;
base_url: string;
is_enabled: boolean;
created_at: string;
updated_at: string;
}
export interface ProviderModelInfo {
id: string;
display_name: string;
context_length?: number | null;
owned_by?: string | null;
}
export interface ProviderTestResult {
success: boolean;
message: string;
models_count?: number | null;
}
function parseErrorText(status: number, body: unknown): string {
if (
body &&
typeof body === "object" &&
"detail" in body &&
typeof body.detail === "string"
) {
return body.detail;
}
if (
body &&
typeof body === "object" &&
"message" in body &&
typeof body.message === "string"
) {
return body.message;
}
return `Request failed (${status})`;
}
async function parseJsonOrThrow<T>(response: Response): Promise<T> {
const body = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(parseErrorText(response.status, body));
}
return body as T;
}
export function isProviderKeyRotationError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const normalized = error.message.toLowerCase();
return (
normalized.includes("public key may have changed") ||
normalized.includes("server key may have changed")
);
}
let cachedPublicKeyPem: string | null = null;
let cachedForgeKey: forge.pki.rsa.PublicKey | null = null;
export function clearProviderPublicKeyCache(): void {
cachedPublicKeyPem = null;
cachedForgeKey = null;
}
async function importProviderPublicKey(
forceRefresh = false,
): Promise<forge.pki.rsa.PublicKey> {
if (!forceRefresh && cachedForgeKey) {
return cachedForgeKey;
}
const response = await authFetch("/api/providers/public-key");
const body = await parseJsonOrThrow<{ public_key: string }>(response);
const publicKeyPem = body.public_key?.trim();
if (!publicKeyPem) {
throw new Error("Provider public key is missing.");
}
if (!forceRefresh && cachedPublicKeyPem === publicKeyPem && cachedForgeKey) {
return cachedForgeKey;
}
const forgeKey = forge.pki.publicKeyFromPem(publicKeyPem);
cachedPublicKeyPem = publicKeyPem;
cachedForgeKey = forgeKey;
return forgeKey;
}
export async function encryptProviderApiKey(
plaintextApiKey: string,
forceRefresh = false,
): Promise<string> {
const key = await importProviderPublicKey(forceRefresh);
const encrypted = key.encrypt(plaintextApiKey, "RSA-OAEP", {
md: forge.md.sha256.create(),
mgf1: { md: forge.md.sha256.create() },
});
return forge.util.encode64(encrypted);
}
export async function listProviderRegistry(): Promise<ProviderRegistryEntry[]> {
const response = await authFetch("/api/providers/registry");
return parseJsonOrThrow<ProviderRegistryEntry[]>(response);
}
export async function listProviderConfigs(): Promise<ProviderConfig[]> {
const response = await authFetch("/api/providers/");
return parseJsonOrThrow<ProviderConfig[]>(response);
}
export async function createProviderConfig(payload: {
providerType: string;
displayName: string;
baseUrl?: string | null;
}): Promise<ProviderConfig> {
const response = await authFetch("/api/providers/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider_type: payload.providerType,
display_name: payload.displayName,
base_url: payload.baseUrl ?? null,
}),
});
return parseJsonOrThrow<ProviderConfig>(response);
}
export async function deleteProviderConfig(providerId: string): Promise<void> {
const response = await authFetch(`/api/providers/${providerId}`, {
method: "DELETE",
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(parseErrorText(response.status, body));
}
}
export async function updateProviderConfig(
providerId: string,
payload: {
displayName?: string;
baseUrl?: string | null;
isEnabled?: boolean;
},
): Promise<ProviderConfig> {
const response = await authFetch(`/api/providers/${providerId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...(payload.displayName === undefined ? {} : { display_name: payload.displayName }),
...(payload.baseUrl === undefined ? {} : { base_url: payload.baseUrl }),
...(payload.isEnabled === undefined ? {} : { is_enabled: payload.isEnabled }),
}),
});
return parseJsonOrThrow<ProviderConfig>(response);
}
async function withApiKeyEncryptionRetry<T>(
plaintextApiKey: string,
call: (encryptedApiKey: string | null) => Promise<T>,
): Promise<T> {
// Empty key (local providers): skip RSA round-trip and let the backend omit auth.
if (!plaintextApiKey) {
return await call(null);
}
try {
const encrypted = await encryptProviderApiKey(plaintextApiKey, false);
return await call(encrypted);
} catch (error) {
if (!isProviderKeyRotationError(error)) {
throw error;
}
clearProviderPublicKeyCache();
const encrypted = await encryptProviderApiKey(plaintextApiKey, true);
return await call(encrypted);
}
}
export async function testProviderConnection(payload: {
providerType: string;
apiKey: string;
baseUrl?: string | null;
}): Promise<ProviderTestResult> {
return withApiKeyEncryptionRetry(payload.apiKey, async (encryptedApiKey) => {
const response = await authFetch("/api/providers/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider_type: payload.providerType,
encrypted_api_key: encryptedApiKey,
base_url: payload.baseUrl ?? null,
}),
});
return parseJsonOrThrow<ProviderTestResult>(response);
});
}
export async function listProviderModels(payload: {
providerType: string;
apiKey: string;
baseUrl?: string | null;
}): Promise<ProviderModelInfo[]> {
return withApiKeyEncryptionRetry(payload.apiKey, async (encryptedApiKey) => {
const response = await authFetch("/api/providers/models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider_type: payload.providerType,
encrypted_api_key: encryptedApiKey,
base_url: payload.baseUrl ?? null,
}),
});
return parseJsonOrThrow<ProviderModelInfo[]>(response);
});
}

View file

@ -3,6 +3,7 @@
import {
type DeletedModelRef,
type ExternalModelOption,
type LoraModelOption,
type ModelOption,
ModelSelector,
@ -40,6 +41,18 @@ import { ChatSettingsPanel } from "./chat-settings-sheet";
import { ContextUsageBar } from "./components/context-usage-bar";
import { ModelLoadInlineStatus } from "./components/model-load-status";
import { db } from "./db";
import {
buildExternalModelId,
isExternalModelId,
parseExternalModelId,
} from "./external-providers";
import {
clampReasoningEffortToLevels,
getExternalReasoningCapabilities,
getProviderCapabilities,
providerSupportsBuiltinCodeExecution,
providerSupportsBuiltinWebSearch,
} from "./provider-capabilities";
import { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
import {
clearTrainingCompareHandoff,
@ -54,6 +67,7 @@ import {
SharedComposer,
} from "./shared-composer";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import { buildChatTourSteps } from "./tour";
import type { ChatView, MessageRecord } from "./types";
@ -536,6 +550,8 @@ export function ChatPage(): ReactElement {
const settingsOpen = useChatRuntimeStore((s) => s.settingsPanelOpen);
const setSettingsOpen = useChatRuntimeStore((s) => s.setSettingsPanelOpen);
const externalProviders = useExternalProvidersStore((s) => s.providers);
const setExternalProviders = useExternalProvidersStore((s) => s.setProviders);
useEffect(() => {
const threadId = search.thread;
@ -596,7 +612,9 @@ export function ChatPage(): ReactElement {
loadProgress,
loadToastDismissed,
} = useChatModelRuntime();
const pendingNativeModelIntent = useNativeIntentStore((state) => state.pendingModelIntent);
const pendingNativeModelIntent = useNativeIntentStore(
(state) => state.pendingModelIntent,
);
const nativePathLeasesSupported = useNativePathLeasesSupported();
const refreshRef = useRef(refresh);
const selectModelRef = useRef(selectModel);
@ -605,9 +623,154 @@ export function ChatPage(): ReactElement {
refreshRef.current = refresh;
selectModelRef.current = selectModel;
}, [refresh, selectModel]);
const isExternalModel = useMemo(
() => isExternalModelId(inferenceParams.checkpoint),
[inferenceParams.checkpoint],
);
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
const activeExternalProvider = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null;
return (
externalProviders.find(
(p) => p.id === selection.providerId,
) ?? null
);
}, [externalProviders, inferenceParams.checkpoint]);
const activeExternalProviderType = activeExternalProvider?.providerType ?? null;
const activeProviderCapabilities = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null;
const provider = externalProviders.find(
(p) => p.id === selection.providerId,
);
const baseCapabilities = getProviderCapabilities(provider?.providerType);
if (!baseCapabilities) return baseCapabilities;
const anthropicThinkingEnabled =
provider?.providerType === "anthropic" &&
reasoningStyle === "reasoning_effort" &&
(supportsReasoningOff ? reasoningEnabled : true) &&
reasoningEffort !== "none";
if (!anthropicThinkingEnabled) return baseCapabilities;
return {
...baseCapabilities,
temperature: false,
topK: false,
};
}, [
externalProviders,
inferenceParams.checkpoint,
reasoningEnabled,
reasoningStyle,
reasoningEffort,
supportsReasoningOff,
]);
useEffect(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return;
const provider = externalProviders.find((p) => p.id === selection.providerId);
const reasoningCaps = getExternalReasoningCapabilities(
provider?.providerType,
selection.modelId,
{ isReasoningProvider: provider?.isReasoningModel === true },
);
const state = useChatRuntimeStore.getState();
const preferredEffort = state.reasoningEffort;
const effortLevels = reasoningCaps.reasoningEffortLevels;
const clampedEffort = clampReasoningEffortToLevels(
preferredEffort,
effortLevels,
);
// Per-provider default effort. Anthropic gets the highest available
// level (xhigh on 4.6/4.7, high on 4.5) since Claude's adaptive
// thinking adjusts cost per turn — sitting at the top of the dial
// gives users the strongest answers and the model can still skip
// thinking when the turn is trivial. OpenAI gets "high" by default
// — the gpt-5.x reasoning models accept high across the board and
// it's the right cost/quality sweet spot for Responses-API tools
// (web search included). Everyone else gets "medium" as a balanced
// default. Users can pick another level via the Think dropdown.
const isAnthropic = provider?.providerType === "anthropic";
const isOpenAI = provider?.providerType === "openai";
const anthropicTopEffort = effortLevels.includes("xhigh")
? "xhigh"
: effortLevels.includes("high")
? "high"
: clampedEffort;
const openaiDefaultEffort = effortLevels.includes("high")
? "high"
: effortLevels.includes("medium")
? "medium"
: clampedEffort;
const nextReasoningEffort = reasoningCaps.supportsReasoning
? isAnthropic
? anthropicTopEffort
: isOpenAI
? openaiDefaultEffort
: effortLevels.includes("medium")
? "medium"
: clampedEffort
: state.reasoningEffort;
const supportsBuiltinWebSearch = providerSupportsBuiltinWebSearch(
provider?.providerType,
);
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
provider?.providerType,
selection.modelId,
provider?.baseUrl,
);
// Kimi's k2.6/k2.5 default to thinking enabled on the server side
// (per https://platform.kimi.ai/docs/models). Mirror that default
// in the UI so the Think pill comes up clicked when the user picks
// a Kimi model. The Search pill stays off by default; the mutual-
// exclusion handlers in the composer flip the two when needed.
const isKimi = provider?.providerType === "kimi";
// Web search is on by default for the two providers we trust most
// for it: Anthropic (web_search_20250305 server tool, structured
// citations) and OpenAI (/v1/responses web_search, structured
// citations). Other providers stay off-by-default — OpenRouter's
// plugins shape and Kimi's $web_search builtin still work when the
// user opts in via the pill, but they're a notch less reliable so
// we don't pre-enable them.
const searchOnByDefault =
supportsBuiltinWebSearch &&
(provider?.providerType === "anthropic" ||
provider?.providerType === "openai");
useChatRuntimeStore.setState({
supportsReasoning: reasoningCaps.supportsReasoning,
reasoningAlwaysOn: reasoningCaps.reasoningAlwaysOn,
reasoningStyle: reasoningCaps.reasoningStyle,
supportsReasoningOff: reasoningCaps.supportsReasoningOff,
reasoningEffortLevels: effortLevels,
reasoningEffort: nextReasoningEffort,
reasoningEnabled: reasoningCaps.supportsReasoning
? reasoningCaps.supportsReasoningOff
? isKimi
? true
: state.reasoningEnabled
: true
: state.reasoningEnabled,
supportsPreserveThinking: false,
// External models never give us a local tool runtime (no
// python sandbox), so `supportsTools` must be false. The two
// `supportsBuiltin*` flags pick up the slack for providers that
// run the tool server-side: `supportsBuiltinWebSearch` lights
// up the Search pill (OpenAI / Anthropic / OpenRouter / Kimi),
// `supportsBuiltinCodeExecution` lights up the Code pill
// (Anthropic Claude 4.x only, today).
supportsTools: false,
supportsBuiltinWebSearch,
supportsBuiltinCodeExecution,
toolsEnabled: searchOnByDefault,
codeToolsEnabled: false,
});
}, [externalProviders, inferenceParams.checkpoint]);
const canCompare = useMemo(() => {
return Boolean(inferenceParams.checkpoint);
}, [inferenceParams.checkpoint]);
return Boolean(inferenceParams.checkpoint) && !isExternalModel;
}, [inferenceParams.checkpoint, isExternalModel]);
// Derive view from URL search params
const view = useMemo<ChatView>(() => {
@ -632,7 +795,8 @@ export function ChatPage(): ReactElement {
const hasActiveModel = Boolean(inferenceParams.checkpoint);
const loadNativeModelIntent = useCallback(
async (intent: NativeIntent, loadingDescription: string) => {
const label = intent.path.displayLabel || intent.displayLabel || "Local GGUF model";
const label =
intent.path.displayLabel || intent.displayLabel || "Local GGUF model";
await selectModel({
id: label,
nativePathToken: intent.path.token,
@ -687,6 +851,7 @@ export function ChatPage(): ReactElement {
(
value: string,
meta?: {
source?: string;
isLora: boolean;
ggufVariant?: string;
isDownloaded?: boolean;
@ -702,6 +867,115 @@ export function ChatPage(): ReactElement {
(meta?.ggufVariant ?? null) === (currentVariant ?? null))
)
return;
if (meta?.source === "external" || isExternalModelId(value)) {
const selectedExternal = parseExternalModelId(value);
const selectedProvider = selectedExternal
? externalProviders.find((p) => p.id === selectedExternal.providerId)
: null;
const reasoningCaps = getExternalReasoningCapabilities(
selectedProvider?.providerType,
selectedExternal?.modelId,
{
isReasoningProvider:
selectedProvider?.isReasoningModel === true,
},
);
const preferredEffort = store.reasoningEffort;
const effortLevels = reasoningCaps.reasoningEffortLevels;
const clampedEffort = clampReasoningEffortToLevels(
preferredEffort,
effortLevels,
);
// Same per-provider default policy as the useEffect path above:
// Anthropic picks the highest available level, OpenAI picks
// "high", everyone else picks "medium".
const isAnthropic = selectedProvider?.providerType === "anthropic";
const isOpenAI = selectedProvider?.providerType === "openai";
const anthropicTopEffort = effortLevels.includes("xhigh")
? "xhigh"
: effortLevels.includes("high")
? "high"
: clampedEffort;
const openaiDefaultEffort = effortLevels.includes("high")
? "high"
: effortLevels.includes("medium")
? "medium"
: clampedEffort;
const nextReasoningEffort = reasoningCaps.supportsReasoning
? isAnthropic
? anthropicTopEffort
: isOpenAI
? openaiDefaultEffort
: effortLevels.includes("medium")
? "medium"
: clampedEffort
: store.reasoningEffort;
// Clear any cached router-picked openrouter/free model unless the
// user is staying on openrouter/free — otherwise the chip would
// keep showing a stale ":<chosen>" suffix from a previous model.
const stillOnOpenRouterFree =
selectedProvider?.providerType === "openrouter" &&
selectedExternal?.modelId === "openrouter/free";
setInferenceParams({
...store.params,
checkpoint: value,
});
const supportsBuiltinWebSearch = providerSupportsBuiltinWebSearch(
selectedProvider?.providerType,
);
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
selectedProvider?.providerType,
selectedExternal?.modelId,
selectedProvider?.baseUrl,
);
// See sibling useEffect above: Kimi's k2.x default to thinking
// enabled, so the Think pill comes up clicked. Search pill stays
// off by default; mutual exclusion flips them via the composer.
const isKimi = selectedProvider?.providerType === "kimi";
// Mirror of sibling useEffect: Anthropic and OpenAI get Search
// on-by-default since their server tools emit structured
// citations end-to-end. OpenRouter and Kimi stay off-by-default.
const searchOnByDefault =
supportsBuiltinWebSearch &&
(selectedProvider?.providerType === "anthropic" ||
selectedProvider?.providerType === "openai");
useChatRuntimeStore.setState({
activeGgufVariant: null,
ggufContextLength: null,
ggufMaxContextLength: null,
ggufNativeContextLength: null,
activeNativePathToken: null,
supportsReasoning: reasoningCaps.supportsReasoning,
reasoningAlwaysOn: reasoningCaps.reasoningAlwaysOn,
reasoningStyle: reasoningCaps.reasoningStyle,
supportsReasoningOff: reasoningCaps.supportsReasoningOff,
reasoningEffortLevels: effortLevels,
reasoningEffort: nextReasoningEffort,
reasoningEnabled: reasoningCaps.supportsReasoning
? reasoningCaps.supportsReasoningOff
? isKimi
? true
: store.reasoningEnabled
: true
: store.reasoningEnabled,
supportsPreserveThinking: false,
// External models have no local tool runtime → supportsTools
// stays false. The two supportsBuiltin* flags carry the
// server-side capability info for each pill:
// - Search → providerSupportsBuiltinWebSearch
// - Code → providerSupportsBuiltinCodeExecution
// (Anthropic Claude 4.x only, today)
supportsTools: false,
supportsBuiltinWebSearch,
supportsBuiltinCodeExecution,
toolsEnabled: searchOnByDefault,
codeToolsEnabled: false,
...(stillOnOpenRouterFree ? {} : { lastOpenRouterChosenModel: null }),
});
return;
}
// Local model picked → drop any cached openrouter/free chosen model.
useChatRuntimeStore.setState({ lastOpenRouterChosenModel: null });
void (async () => {
let showImageCompatibilityWarning = false;
if (view.mode === "single" && activeThreadId) {
@ -738,7 +1012,14 @@ export function ChatPage(): ReactElement {
});
})();
},
[activeThreadId, modelsFromStore, selectModel, view],
[
activeThreadId,
externalProviders,
modelsFromStore,
selectModel,
setInferenceParams,
view,
],
);
const handleEject = useCallback(() => {
void ejectModel();
@ -813,6 +1094,47 @@ export function ChatPage(): ReactElement {
})),
[modelsFromStore],
);
const lastOpenRouterChosenModel = useChatRuntimeStore(
(s) => s.lastOpenRouterChosenModel,
);
const externalModels = useMemo<ExternalModelOption[]>(
() =>
externalProviders.flatMap((provider) =>
provider.models.map((model) => {
// For OpenRouter's free router we know which underlying free
// model the gateway actually picked once a stream completes
// (chat-adapter latches `chunk.model` into the runtime store).
// Render the chip as `openrouter:<short-chosen>` — drop the
// redundant `/free` from the router id and the org prefix
// from the chosen id (e.g.
// openrouter/free + inclusionai/ring-2.6-1t-20260508:free
// -> openrouter:ring-2.6-1t-20260508:free
// ). The `:free` suffix on the chosen id already conveys
// 'free model', so the leading `/free` is noise.
let displayName = model;
if (
provider.providerType === "openrouter" &&
model === "openrouter/free" &&
lastOpenRouterChosenModel
) {
const lastSlash = lastOpenRouterChosenModel.lastIndexOf("/");
const shortChosen =
lastSlash >= 0
? lastOpenRouterChosenModel.slice(lastSlash + 1)
: lastOpenRouterChosenModel;
displayName = `openrouter:${shortChosen}`;
}
return {
id: buildExternalModelId(provider.id, model),
name: displayName,
providerId: provider.id,
providerName: provider.name,
providerType: provider.providerType,
};
}),
),
[externalProviders, lastOpenRouterChosenModel],
);
const [localModels, setLocalModels] = useState<LoraModelOption[]>([]);
@ -847,20 +1169,24 @@ export function ChatPage(): ReactElement {
.catch(() => {});
}, [navigate]);
const refreshModelLists = useCallback((deletedModel?: DeletedModelRef) => {
const { checkpoint } = useChatRuntimeStore.getState().params;
const activeGgufVariant = useChatRuntimeStore.getState().activeGgufVariant;
if (
modelMatchesDeleted(
{ id: checkpoint, ggufVariant: activeGgufVariant },
deletedModel,
)
) {
useChatRuntimeStore.getState().clearCheckpoint();
}
void refresh();
refreshLocalModels();
}, [refresh, refreshLocalModels]);
const refreshModelLists = useCallback(
(deletedModel?: DeletedModelRef) => {
const { checkpoint } = useChatRuntimeStore.getState().params;
const activeGgufVariant =
useChatRuntimeStore.getState().activeGgufVariant;
if (
modelMatchesDeleted(
{ id: checkpoint, ggufVariant: activeGgufVariant },
deletedModel,
)
) {
useChatRuntimeStore.getState().clearCheckpoint();
}
void refresh();
refreshLocalModels();
},
[refresh, refreshLocalModels],
);
const loraModels = useMemo<LoraModelOption[]>(() => {
const fromLoras = lorasFromStore.map((lora) => ({
@ -1001,6 +1327,7 @@ export function ChatPage(): ReactElement {
<ModelSelector
models={models}
loraModels={loraModels}
externalModels={externalModels}
value={inferenceParams.checkpoint}
activeGgufVariant={activeGgufVariant}
onValueChange={handleCheckpointChange}
@ -1014,6 +1341,7 @@ export function ChatPage(): ReactElement {
onOpenChange={handleModelSelectorOpenChange}
triggerDataTour="chat-model-selector"
contentDataTour="chat-model-selector-popover"
showCloudIndicator={isExternalModel}
className="max-w-[62vw] !pr-3 sm:max-w-none !h-[34px]"
/>
)}
@ -1120,6 +1448,17 @@ export function ChatPage(): ReactElement {
onOpenChange={setSettingsOpen}
params={inferenceParams}
onParamsChange={setInferenceParams}
isExternalModel={isExternalModel}
providerCapabilities={activeProviderCapabilities}
activeExternalProvider={activeExternalProvider}
onExternalProviderChange={(updatedProvider) => {
setExternalProviders(
externalProviders.map((provider) =>
provider.id === updatedProvider.id ? updatedProvider : provider,
),
);
}}
externalProviderType={activeExternalProviderType}
onReloadModel={() => {
const state = useChatRuntimeStore.getState();
if (state.params.checkpoint) {

File diff suppressed because it is too large Load diff

View file

@ -64,6 +64,12 @@ import { Fragment, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import {
type ExternalProviderConfig,
getExternalProviderApiKey,
parseExternalModelId,
supportsProviderPromptCaching,
} from "./external-providers";
import {
applyPresetParams,
BUILTIN_PRESET_NAMES,
@ -80,6 +86,13 @@ import {
toPresetParams,
type Preset,
} from "./presets/preset-policy";
import { OpenAICodeExecSection } from "./components/openai-code-exec-section";
import {
EXTERNAL_MAX_OUTPUT_TOKENS,
getExternalMinOutputTokens,
providerSupportsBuiltinCodeExecution,
type ProviderCapabilities,
} from "./provider-capabilities";
import type { InferenceParams } from "./types/runtime";
export { defaultInferenceParams, type Preset } from "./presets/preset-policy";
@ -505,6 +518,21 @@ interface ChatSettingsPanelProps {
onOpenChange?: (open: boolean) => void;
params: InferenceParams;
onParamsChange: (params: InferenceParams) => void;
isExternalModel?: boolean;
/**
* Sampling-param capability set for the active external provider, or `null`
* for local models (in which case every knob is rendered). Drives the
* per-param visibility in the sampling section.
*/
providerCapabilities?: ProviderCapabilities | null;
activeExternalProvider?: ExternalProviderConfig | null;
onExternalProviderChange?: (provider: ExternalProviderConfig) => void;
/**
* Backend provider type for the active external model (e.g. "kimi",
* "anthropic", "openai"), or `null` for local models. Drives the
* per-provider Max Tokens floor in the slider.
*/
externalProviderType?: string | null;
onReloadModel?: () => void;
}
@ -513,11 +541,30 @@ export function ChatSettingsPanel({
onOpenChange,
params,
onParamsChange,
isExternalModel = false,
providerCapabilities = null,
activeExternalProvider = null,
onExternalProviderChange,
externalProviderType = null,
onReloadModel,
}: ChatSettingsPanelProps) {
// For non-external (local) models we show every knob — providerCapabilities
// is only consulted when `isExternalModel` is true. An external model with an
// unknown provider falls back to the OpenAI-compat shape via
// getProviderCapabilities, so these flags never undercount support.
const showTemperature =
!isExternalModel || Boolean(providerCapabilities?.temperature);
const showTopP = !isExternalModel || Boolean(providerCapabilities?.topP);
const showTopK = !isExternalModel || Boolean(providerCapabilities?.topK);
const showMinP = !isExternalModel || Boolean(providerCapabilities?.minP);
const showRepetitionPenalty =
!isExternalModel || Boolean(providerCapabilities?.repetitionPenalty);
const showPresencePenalty =
!isExternalModel || Boolean(providerCapabilities?.presencePenalty);
const isMobile = useIsMobile();
const isGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null;
const hasModelContent = isGguf || Boolean(params.checkpoint);
const hasModelContent =
!isExternalModel && (isGguf || Boolean(params.checkpoint));
const speculativeType = useChatRuntimeStore((s) => s.speculativeType);
const setSpeculativeType = useChatRuntimeStore((s) => s.setSpeculativeType);
const loadedSpeculativeType = useChatRuntimeStore(
@ -627,6 +674,26 @@ export function ChatSettingsPanel({
Boolean(currentCheckpoint) &&
modelRequiresTrustRemoteCode &&
!(params.trustRemoteCode ?? false);
const showPromptCachingControl =
activeExternalProvider != null &&
supportsProviderPromptCaching(activeExternalProvider.providerType);
const promptCachingEnabled =
activeExternalProvider?.enablePromptCaching !== false;
const externalSelection = currentCheckpoint
? parseExternalModelId(currentCheckpoint)
: null;
const showOpenAICodeExecSection =
activeExternalProvider != null &&
providerSupportsBuiltinCodeExecution(
activeExternalProvider.providerType,
externalSelection?.modelId,
activeExternalProvider.baseUrl,
) &&
activeExternalProvider.providerType === "openai";
const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
const openAiApiKeyForSection = activeExternalProvider
? getExternalProviderApiKey(activeExternalProvider.id) || null
: null;
function set<K extends keyof InferenceParams>(key: K) {
return (v: InferenceParams[K]) => {
@ -1110,6 +1177,43 @@ export function ChatSettingsPanel({
</div>
</CollapsibleSection>
{showPromptCachingControl && activeExternalProvider ? (
<CollapsibleSection label="Provider" defaultOpen={true}>
<div className="flex items-center justify-between gap-3 pt-1">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Prompt caching
</span>
<InfoHint>
Reuse compatible prompt prefixes for lower latency and cost.
</InfoHint>
</div>
<Switch
className="panel-switch shrink-0"
checked={promptCachingEnabled}
onCheckedChange={(checked) => {
onExternalProviderChange?.({
...activeExternalProvider,
enablePromptCaching: checked,
});
}}
aria-label="Enable prompt caching"
/>
</div>
</CollapsibleSection>
) : null}
{showOpenAICodeExecSection && activeExternalProvider ? (
<CollapsibleSection label="Code Execution" defaultOpen={false}>
<OpenAICodeExecSection
provider={activeExternalProvider}
apiKey={openAiApiKeyForSection}
activeThreadId={activeThreadId}
onProviderChange={(p) => onExternalProviderChange?.(p)}
/>
</CollapsibleSection>
) : null}
<CollapsibleSection label="System Prompt" defaultOpen={true}>
<button
type="button"
@ -1131,65 +1235,79 @@ export function ChatSettingsPanel({
<CollapsibleSection label="Sampling" defaultOpen={true}>
<div className="flex flex-col gap-5 pt-1">
<ParamSlider
label="Temperature"
value={params.temperature}
min={0}
max={2}
step={0.01}
onChange={set("temperature")}
info="Controls randomness. Lower values make output focused and deterministic; higher values increase variety and creativity."
/>
<ParamSlider
label="Top P"
value={params.topP}
min={0}
max={1}
step={0.05}
onChange={set("topP")}
displayValue={params.topP === 1 ? "Off" : undefined}
info="Nucleus sampling. Restricts choices to the smallest set of tokens whose cumulative probability reaches this threshold. 1.0 = off."
/>
<ParamSlider
label="Top K"
value={params.topK}
min={0}
max={100}
step={1}
onChange={set("topK")}
displayValue={params.topK === 0 ? "Off" : undefined}
info="Limits sampling to the K most likely tokens at each step. 0 = off."
/>
<ParamSlider
label="Min P"
value={params.minP}
min={0}
max={1}
step={0.01}
onChange={set("minP")}
info="Drops tokens whose probability is below this fraction of the top token's probability. Filters unlikely candidates."
/>
<ParamSlider
label="Repetition Penalty"
value={params.repetitionPenalty}
min={1}
max={2}
step={0.05}
onChange={set("repetitionPenalty")}
displayValue={params.repetitionPenalty === 1 ? "Off" : undefined}
info="Down-weights tokens that have already appeared, reducing repetition. 1.0 = off; higher values penalize more strongly."
/>
<ParamSlider
label="Presence Penalty"
value={params.presencePenalty}
min={0}
max={2}
step={0.1}
onChange={set("presencePenalty")}
displayValue={params.presencePenalty === 0 ? "Off" : undefined}
info="Penalizes any token that has already appeared at least once, encouraging the model to introduce new topics. 0 = off."
/>
{!isGguf && (
{showTemperature ? (
<ParamSlider
label="Temperature"
value={params.temperature}
min={0}
max={2}
step={0.01}
onChange={set("temperature")}
info="Controls randomness. Lower values make output focused and deterministic; higher values increase variety and creativity."
/>
) : null}
{showTopP ? (
<ParamSlider
label="Top P"
value={params.topP}
min={0}
max={1}
step={0.05}
onChange={set("topP")}
displayValue={params.topP === 1 ? "Off" : undefined}
info="Nucleus sampling. Restricts choices to the smallest set of tokens whose cumulative probability reaches this threshold. 1.0 = off."
/>
) : null}
{showTopK ? (
<ParamSlider
label="Top K"
value={params.topK}
min={0}
max={100}
step={1}
onChange={set("topK")}
displayValue={params.topK === 0 ? "Off" : undefined}
info="Limits sampling to the K most likely tokens at each step. 0 = off."
/>
) : null}
{showMinP ? (
<ParamSlider
label="Min P"
value={params.minP}
min={0}
max={1}
step={0.01}
onChange={set("minP")}
info="Drops tokens whose probability is below this fraction of the top token's probability. Filters unlikely candidates."
/>
) : null}
{showRepetitionPenalty ? (
<ParamSlider
label="Repetition Penalty"
value={params.repetitionPenalty}
min={1}
max={2}
step={0.05}
onChange={set("repetitionPenalty")}
displayValue={
params.repetitionPenalty === 1 ? "Off" : undefined
}
info="Down-weights tokens that have already appeared, reducing repetition. 1.0 = off; higher values penalize more strongly."
/>
) : null}
{showPresencePenalty ? (
<ParamSlider
label="Presence Penalty"
value={params.presencePenalty}
min={0}
max={2}
step={0.1}
onChange={set("presencePenalty")}
displayValue={params.presencePenalty === 0 ? "Off" : undefined}
info="Penalizes any token that has already appeared at least once, encouraging the model to introduce new topics. 0 = off."
/>
) : null}
{!isExternalModel && !isGguf && (
<ParamSlider
label="Max Seq Length"
value={params.maxSeqLength}
@ -1203,8 +1321,18 @@ export function ChatSettingsPanel({
<ParamSlider
label="Max Tokens"
value={params.maxTokens}
min={64}
max={isGguf && ggufContextLength ? ggufContextLength : 32768}
min={
isExternalModel
? getExternalMinOutputTokens(externalProviderType)
: 64
}
max={
isExternalModel
? EXTERNAL_MAX_OUTPUT_TOKENS
: isGguf && ggufContextLength
? ggufContextLength
: 32768
}
step={64}
onChange={set("maxTokens")}
displayValue={
@ -1219,13 +1347,15 @@ export function ChatSettingsPanel({
</div>
</CollapsibleSection>
<CollapsibleSection label="Tools">
<div className="flex flex-col gap-5 pt-1">
<AutoHealToolCallsToggle />
<MaxToolCallsSlider />
<ToolCallTimeoutSlider />
</div>
</CollapsibleSection>
{!isExternalModel ? (
<CollapsibleSection label="Tools">
<div className="flex flex-col gap-5 pt-1">
<AutoHealToolCallsToggle />
<MaxToolCallsSlider />
<ToolCallTimeoutSlider />
</div>
</CollapsibleSection>
) : null}
</div>
</div>
<Dialog

View file

@ -0,0 +1,659 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* Settings-sheet section for OpenAI shell-tool container management.
* Renders only when:
* - active provider is OpenAI cloud (api.openai.com base URL), AND
* - the active model is gpt-5.5 or gpt-5.5-pro (the only families
* where the shell tool is wired through today).
*
* Surfaces three controls:
* 1. Default container idle-timeout (minutes). Persists on the
* provider record; pre-fills the create dialog and is used by
* the chat-adapter's lazy-create path on the first turn of a
* thread.
* 2. Container picker for the *active thread* pick any of the
* user's existing OpenAI containers, or "Auto-create per thread"
* (default; lets the auto-create path manage it).
* 3. Create-new-container inline form. Refresh + delete actions
* per row.
*
* State persistence:
* - TTL ExternalProviderConfig.openaiContainerTtlMinutes
* - Active container for this thread ThreadRecord.openaiCodeExecContainerId
*
* No new global stores list is fetched on open / refresh and held
* in component state.
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { TrashIcon, RefreshCwIcon, PlusIcon } from "lucide-react";
import {
createOpenAIContainer,
deleteOpenAIContainer,
listOpenAIContainers,
type OpenAIContainerSummary,
} from "../api/openai-containers";
import { db } from "../db";
import type { ExternalProviderConfig } from "../external-providers";
import { useLiveQuery } from "../db";
import { ensureThreadRecord } from "../runtime-provider";
const AUTO_OPTION_VALUE = "__auto__";
const DEFAULT_TTL_MINUTES = 20;
const TTL_MIN = 1;
const TTL_MAX = 20; // OpenAI hard cap on expires_after.minutes
// Cadence for re-fetching the container list while the section is
// mounted. OpenAI's container TTL flips at minute granularity, so 30s
// is fast enough that an expired container loses its ACTIVE pill within
// half a minute without hammering /v1/containers.
const REFRESH_POLL_MS = 30_000;
function ageLabel(epochSeconds: number | null | undefined): string {
if (!epochSeconds) return "";
const ageSec = Math.max(0, Math.floor(Date.now() / 1000) - epochSeconds);
if (ageSec < 60) return `${ageSec}s ago`;
const ageMin = Math.floor(ageSec / 60);
if (ageMin < 60) return `${ageMin}m ago`;
const ageHr = Math.floor(ageMin / 60);
if (ageHr < 48) return `${ageHr}h ago`;
const ageDay = Math.floor(ageHr / 24);
return `${ageDay}d ago`;
}
function shortContainerId(id: string): string {
// Mid-truncate keeps the "cntr_" prefix readable and still surfaces the
// tail digits users sometimes copy off OpenAI's dashboard.
if (id.length <= 18) return id;
return `${id.slice(0, 12)}${id.slice(-4)}`;
}
function isContainerRunning(c: OpenAIContainerSummary): boolean {
// OpenAI's containers API reports `status: "running"` while idle TTL is
// valid and `status: "expired"` once the idle window has passed. Treat
// a missing status as running so we don't false-positive on any older
// payloads that didn't include the field.
return c.status == null || c.status === "running";
}
interface OpenAICodeExecSectionProps {
provider: ExternalProviderConfig;
apiKey: string | null;
activeThreadId: string | null;
onProviderChange: (provider: ExternalProviderConfig) => void;
}
export function OpenAICodeExecSection({
provider,
apiKey,
activeThreadId,
onProviderChange,
}: OpenAICodeExecSectionProps) {
const [containers, setContainers] = useState<OpenAIContainerSummary[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState("");
const [createTtl, setCreateTtl] = useState<number>(
provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES,
);
// Ids that have been deleted in this session. Once tombstoned, an id
// stays hidden from the picker for the lifetime of the page — OpenAI's
// /containers list can keep returning a freshly-deleted id for an
// undocumented and variable amount of time, and an automatic re-show
// creates more confusion than it solves. Refreshing the page resets
// the tombstone naturally.
const [tombstones, setTombstones] = useState<Set<string>>(() => new Set());
// Target row for the destructive confirmation dialog. Held in state
// (rather than blocking with window.confirm) so the dialog sits inside
// the settings sheet instead of a native browser alert.
const [pendingDelete, setPendingDelete] =
useState<OpenAIContainerSummary | null>(null);
const [deleting, setDeleting] = useState(false);
const thread = useLiveQuery(
async () => (activeThreadId ? db.threads.get(activeThreadId) : undefined),
[activeThreadId],
);
const activeContainerId = thread?.openaiCodeExecContainerId ?? null;
// Hide just-deleted containers even if OpenAI's list still returns them.
// This is the single chokepoint — every downstream view (sorted picker,
// auto-bind candidate, all-containers list) derives from visibleContainers.
const visibleContainers = useMemo(() => {
if (tombstones.size === 0) return containers;
return containers.filter((c) => !tombstones.has(c.id));
}, [containers, tombstones]);
// Containers sorted newest-first by lastActiveAt so the dropdown's
// default (auto-bind target) shows up first.
const sortedContainers = useMemo(
() =>
[...visibleContainers].sort(
(a, b) => (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0),
),
[visibleContainers],
);
// First running container by lastActiveAt — the auto-bind target and
// also what we surface visually before Dexie catches up.
const firstRunningContainer = useMemo(
() => sortedContainers.find(isContainerRunning) ?? null,
[sortedContainers],
);
// What the picker should treat as "active" right now. We decouple
// this from `activeContainerId` (Dexie state) so the user immediately
// sees the most-recent running container while the auto-bind effect's
// async write propagates. If the Dexie-bound container has since
// expired, fall back to the first running candidate — the stale-bind
// sweeper below will clear Dexie shortly after.
const boundContainer = useMemo(
() => sortedContainers.find((c) => c.id === activeContainerId) ?? null,
[sortedContainers, activeContainerId],
);
const displayedContainerId =
(boundContainer && isContainerRunning(boundContainer)
? boundContainer.id
: firstRunningContainer?.id) ?? null;
const refresh = useCallback(async () => {
if (!apiKey) return;
setIsLoading(true);
try {
const list = await listOpenAIContainers({
apiKey,
baseUrl: provider.baseUrl || null,
});
setContainers(list);
} catch (err) {
toast.error(
`Failed to list containers: ${err instanceof Error ? err.message : "Unknown"}`,
);
} finally {
setIsLoading(false);
}
}, [apiKey, provider.baseUrl]);
// Fetch once when the section mounts (or provider changes), then
// poll on a low cadence so an expired container's ACTIVE pill clears
// without the user clicking the refresh button. Also re-fetch when
// the tab regains visibility — covers the common case of leaving the
// sheet open across a long idle period.
useEffect(() => {
void refresh();
const interval = window.setInterval(() => {
if (document.visibilityState === "visible") {
void refresh();
}
}, REFRESH_POLL_MS);
const onVisibility = () => {
if (document.visibilityState === "visible") {
void refresh();
}
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
window.clearInterval(interval);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [refresh]);
// Auto-bind the active thread to the most-recently-active container
// whenever the thread has none set and at least one container exists
// on the user's OpenAI account. Sorting by `lastActiveAt` matches
// what feels "most recent" from the user's perspective.
//
// We eagerly materialize the thread row via `ensureThreadRecord` so
// the bind actually lands in Dexie before the user has sent a first
// message. This does NOT create anything at OpenAI — only a local
// ThreadRecord — so it does not bypass the user's expectation that
// a fresh OpenAI container is not created until first send.
//
// If `containers` is empty (no OpenAI containers exist yet), this
// effect short-circuits: the picker renders an empty-state hint and
// the chat-adapter's lazy-create path will mint the first container
// on first send.
useEffect(() => {
if (
!activeThreadId ||
activeContainerId ||
visibleContainers.length === 0
) {
return;
}
const sorted = [...visibleContainers].sort(
(a, b) => (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0),
);
const candidate = sorted[0];
if (!candidate) return;
void (async () => {
try {
await ensureThreadRecord({
threadId: activeThreadId,
modelType: "base",
});
await db.threads.update(activeThreadId, {
openaiCodeExecContainerId: candidate.id,
});
} catch {
// Best-effort; the chat-adapter will inherit/create on send.
}
})();
}, [activeThreadId, activeContainerId, visibleContainers]);
const ttlValue = provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES;
const onTtlChange = (raw: string) => {
const n = parseInt(raw, 10);
if (Number.isNaN(n)) return;
const clamped = Math.min(Math.max(n, TTL_MIN), TTL_MAX);
onProviderChange({ ...provider, openaiContainerTtlMinutes: clamped });
};
const onPick = async (value: string) => {
if (!activeThreadId || !value) return;
// value is always a container id now — the "Auto-create per thread"
// option has been removed in favour of always defaulting to the
// most-recently-active container. The chat-adapter still handles
// the no-containers-exist case (lazy-create on first send).
//
// ensureThreadRecord materializes the thread row eagerly (modelType
// "base" — settings sheet is single-thread-mode only) so the update
// actually lands when the user hasn't sent a message yet.
try {
await ensureThreadRecord({ threadId: activeThreadId, modelType: "base" });
const affected = await db.threads.update(activeThreadId, {
openaiCodeExecContainerId: value,
});
if (affected === 0) {
toast.error("Could not update thread.");
}
} catch (err) {
toast.error(
`Could not update thread: ${err instanceof Error ? err.message : "Unknown"}`,
);
}
};
const onCreate = async () => {
if (!apiKey) return;
const name = createName.trim();
if (!name) {
toast.error("Container name is required");
return;
}
setCreating(true);
try {
const created = await createOpenAIContainer(
{ apiKey, baseUrl: provider.baseUrl || null },
{ name, ttlMinutes: createTtl },
);
toast.success(`Created container ${name}`);
setCreateName("");
setCreateOpen(false);
// Auto-bind the just-created container to the active thread.
// ensureThreadRecord first so the bind lands even when the user
// creates a container before sending the first message — without
// it, db.threads.update silently affects 0 rows and the chat
// adapter falls back to cross-thread inheritance / lazy-create,
// which can pick a stale container that fails with "container
// does not exist" on the first turn.
if (activeThreadId) {
try {
await ensureThreadRecord({
threadId: activeThreadId,
modelType: "base",
});
await db.threads.update(activeThreadId, {
openaiCodeExecContainerId: created.id,
});
} catch {
/* best-effort; toast above already confirmed creation */
}
}
} catch (err) {
toast.error(
`Create failed: ${err instanceof Error ? err.message : "Unknown"}`,
);
} finally {
setCreating(false);
// Refresh even on failure: the request may have partially succeeded
// server-side (created container, lost response), and a re-fetch
// keeps the picker in sync with OpenAI's actual state.
await refresh();
}
};
const confirmDelete = async () => {
if (!apiKey || !pendingDelete) return;
const { id, name } = pendingDelete;
setDeleting(true);
try {
await deleteOpenAIContainer(
{ apiKey, baseUrl: provider.baseUrl || null },
id,
);
// Tombstone the id so the picker hides it immediately even if
// OpenAI's list keeps returning it for a while.
setTombstones((prev) => {
if (prev.has(id)) return prev;
const next = new Set(prev);
next.add(id);
return next;
});
// Clear any thread bindings pointing at the now-deleted id.
const affected = await db.threads
.filter((t) => t.openaiCodeExecContainerId === id)
.toArray();
await Promise.all(
affected.map((t) =>
db.threads.update(t.id, { openaiCodeExecContainerId: null }),
),
);
toast.success(`Deleted container ${name || id}`);
} catch (err) {
toast.error(
`Delete failed: ${err instanceof Error ? err.message : "Unknown"}`,
);
} finally {
setDeleting(false);
setPendingDelete(null);
// Always refresh so a stale list entry (e.g. container deleted
// elsewhere, or already expired) is purged from the UI even when
// the delete call itself errored.
await refresh();
}
};
const displayActiveId = displayedContainerId;
return (
<div className="flex flex-col gap-3 pt-1">
{/* TTL */}
<div className="flex items-center justify-between gap-3">
<label
htmlFor="openai-container-ttl"
className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg"
>
New-container idle timeout (min, max 20)
</label>
<Input
id="openai-container-ttl"
type="number"
min={TTL_MIN}
max={TTL_MAX}
value={ttlValue}
onChange={(e) => onTtlChange(e.target.value)}
className="h-8 w-14 px-2 text-center text-sm tabular-nums"
/>
</div>
{/* Single container list. The previously-separate "Active for
this thread" picker collapses into this list: clicking a row
binds it to the active thread, and the ACTIVE pill marks
which one. Avoids duplicating state across two controls. */}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
Containers
</span>
<Button
size="sm"
variant="ghost"
className="-mr-1 h-6 w-6 p-0 text-muted-foreground"
onClick={() => void refresh()}
disabled={isLoading || !apiKey}
aria-label="Refresh container list"
>
<RefreshCwIcon
className={`size-3.5 ${isLoading ? "animate-spin" : ""}`}
/>
</Button>
</div>
{/* When no containers exist yet, render a disabled placeholder
instead of the picker. The first one is created by the
chat-adapter on first send (lazy-create) and will appear
here after the next refresh. */}
{sortedContainers.length === 0 ? (
<div className="h-9 w-full rounded-md border border-primary/40 bg-background px-2 flex items-center text-sm text-muted-foreground">
(none yet will be created on first send)
</div>
) : (
<select
value={displayedContainerId ?? sortedContainers[0].id}
onChange={(e) => onPick(e.target.value)}
disabled={!activeThreadId}
className="h-9 w-full rounded-md border border-primary/40 bg-background px-2 text-sm font-medium shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
>
{sortedContainers.map((c) => (
<option key={c.id} value={c.id}>
{c.name ?? "(unnamed)"} · {c.id.slice(0, 14)}
{c.lastActiveAt ? ` · active ${ageLabel(c.lastActiveAt)}` : ""}
</option>
))}
</select>
)}
</div>
{/* Container list with delete actions labeled and visually
quieter so it's clearly the "all containers, manage them"
area rather than the active selector above. */}
<div className="flex flex-col gap-1.5">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
All containers
</span>
{isLoading && visibleContainers.length === 0 ? (
<Skeleton className="h-16 w-full" />
) : sortedContainers.length > 0 ? (
<ul className="flex max-h-52 flex-col gap-1 overflow-auto">
{sortedContainers.map((c) => {
const running = isContainerRunning(c);
const isActive = running && c.id === displayActiveId;
const ttlMinutes = c.expiresAfterMinutes ?? DEFAULT_TTL_MINUTES;
const canActivate =
activeThreadId != null && !isActive && running;
const statusLabel = !running ? (c.status ?? "expired") : null;
return (
<li
key={c.id}
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-xs transition-colors ${
isActive
? "border-primary/30 bg-primary/5"
: "border-border/60 hover:bg-muted/40"
} ${canActivate ? "cursor-pointer" : ""} ${
running ? "" : "opacity-60"
}`}
onClick={() => {
if (canActivate) void onPick(c.id);
}}
onKeyDown={(e) => {
if (!canActivate) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
void onPick(c.id);
}
}}
tabIndex={canActivate ? 0 : undefined}
role={canActivate ? "button" : undefined}
aria-pressed={isActive}
title={
canActivate
? "Use this container for the active thread"
: !running
? `Container is ${statusLabel}`
: undefined
}
>
{/* min-w-0 + truncate keeps long OpenAI container ids
from spilling under the trash button on narrow
settings sheets. */}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate font-medium">
{c.name ?? "(unnamed)"}
</span>
{isActive ? (
<span className="shrink-0 rounded-sm bg-primary/15 px-1 py-px text-[9px] font-medium uppercase tracking-wider text-primary">
Active
</span>
) : statusLabel ? (
<span className="shrink-0 rounded-sm bg-muted px-1 py-px text-[9px] font-medium uppercase tracking-wider text-muted-foreground">
{statusLabel}
</span>
) : null}
</div>
<div
className="flex min-w-0 items-center gap-1.5 text-muted-foreground"
title={c.id}
>
<span className="min-w-0 truncate font-mono text-[11px]">
{shortContainerId(c.id)}
</span>
<span className="shrink-0 text-[10px] uppercase tracking-wider">
· {ttlMinutes}m
</span>
</div>
</div>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 shrink-0 p-0 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
setPendingDelete(c);
}}
aria-label={`Delete container ${c.name ?? c.id}`}
>
<TrashIcon className="size-3.5" />
</Button>
</li>
);
})}
</ul>
) : (
<p className="text-xs text-muted-foreground">
None yet one will be created on first send.
</p>
)}
</div>
{/* Create new */}
{createOpen ? (
<div className="flex flex-col gap-2 rounded-md border border-border/60 p-2">
<Input
placeholder="Container name (e.g. data-analysis)"
value={createName}
onChange={(e) => setCreateName(e.target.value)}
className="h-8 text-sm"
/>
<div className="flex items-center gap-2">
<Input
type="number"
min={TTL_MIN}
max={TTL_MAX}
value={createTtl}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (!Number.isNaN(n))
setCreateTtl(Math.min(Math.max(n, TTL_MIN), TTL_MAX));
}}
className="h-8 w-24 text-sm"
aria-label="Idle timeout in minutes"
/>
<span className="text-xs text-muted-foreground">min idle</span>
<div className="flex-1" />
<Button
size="sm"
variant="ghost"
className="h-7"
onClick={() => {
setCreateOpen(false);
setCreateName("");
}}
disabled={creating}
>
Cancel
</Button>
<Button
size="sm"
className="h-7"
onClick={() => void onCreate()}
disabled={creating || !createName.trim() || !apiKey}
>
Create
</Button>
</div>
</div>
) : (
<Button
size="sm"
variant="outline"
className="h-8"
onClick={() => {
setCreateTtl(ttlValue);
setCreateOpen(true);
}}
disabled={!apiKey}
>
<PlusIcon className="size-3.5 mr-1" />
New container
</Button>
)}
<AlertDialog
open={pendingDelete !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen && deleting) return;
if (!nextOpen) setPendingDelete(null);
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>
Delete{" "}
<span className="font-mono">
{pendingDelete?.name ?? "container"}
</span>
?
</AlertDialogTitle>
<AlertDialogDescription>
Threads using this container will fall back to auto-create on
their next turn. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={(e) => {
e.preventDefault();
void confirmDelete();
}}
>
{deleting ? "Deleting…" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -0,0 +1,387 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export interface ExternalProviderConfig {
id: string;
/** Backend provider type (e.g. openai, mistral, gemini). */
providerType: string;
/** Display name in UI. */
name: string;
/** Provider base URL (default from registry or backend-saved override). */
baseUrl: string;
/** Model ids user enabled from `/api/providers/models`. */
models: string[];
/** Cached available model ids from the provider's /models response. */
availableModels?: string[];
/** Whether to ask supported hosted providers to use prompt caching. */
enablePromptCaching?: boolean;
/** User-pinned: the loaded vLLM model supports `enable_thinking`. */
isReasoningModel?: boolean;
/**
* Default idle-timeout (in minutes) for newly created OpenAI shell
* containers. Pre-fills the "Create container" dialog and is the
* TTL the auto-create-per-thread path POSTs to /v1/containers with.
* OpenAI's hard default is 20. Only meaningful for OpenAI cloud.
*/
openaiContainerTtlMinutes?: number;
createdAt: number;
updatedAt: number;
}
const PROMPT_CACHING_PROVIDER_TYPES = new Set(["openai", "anthropic"]);
export function supportsProviderPromptCaching(
providerType: string | null | undefined,
): boolean {
return providerType != null && PROMPT_CACHING_PROVIDER_TYPES.has(providerType);
}
// Provider types that expose the connection-level "reasoning model"
// toggle. vLLM's OpenAI-compat endpoint doesn't advertise this per model.
const REASONING_TOGGLE_PROVIDER_TYPES = new Set(["vllm"]);
export function supportsProviderReasoningToggle(
providerType: string | null | undefined,
): boolean {
return (
providerType != null && REASONING_TOGGLE_PROVIDER_TYPES.has(providerType)
);
}
export const CUSTOM_BACKEND_PROVIDER_TYPE = "openai";
export const LEGACY_CUSTOM_PROVIDER_TYPE = "custom";
export const CUSTOM_PROVIDER_PRESETS = [
{
providerType: "llama_cpp",
displayName: "llama.cpp",
baseUrlPlaceholder: "http://localhost:8080/v1",
modelIdsPlaceholder: "gpt-oss-20b\nqwen3-14b",
},
{
providerType: "vllm",
displayName: "vLLM",
baseUrlPlaceholder: "https://my-vllm-server.com/v1",
modelIdsPlaceholder: "openai/gpt-oss-20b\nQwen/Qwen3-14B",
},
{
providerType: "ollama",
displayName: "Ollama",
baseUrlPlaceholder: "http://localhost:11434/v1",
modelIdsPlaceholder: "gpt-oss:20b\nqwen3:14b",
},
] as const;
const CUSTOM_PROVIDER_LABELS: Record<string, string> = {
[LEGACY_CUSTOM_PROVIDER_TYPE]: "Custom",
...Object.fromEntries(
CUSTOM_PROVIDER_PRESETS.map((preset) => [
preset.providerType,
preset.displayName,
]),
),
};
const CUSTOM_PROVIDER_BASE_URL_PLACEHOLDERS: Record<string, string> = {
[LEGACY_CUSTOM_PROVIDER_TYPE]: "https://my-vllm-server.com/v1",
...Object.fromEntries(
CUSTOM_PROVIDER_PRESETS.map((preset) => [
preset.providerType,
preset.baseUrlPlaceholder,
]),
),
};
const CUSTOM_PROVIDER_MODEL_IDS_PLACEHOLDERS: Record<string, string> = {
[LEGACY_CUSTOM_PROVIDER_TYPE]: "openai/gpt-oss-20b\nQwen/Qwen3-14B",
...Object.fromEntries(
CUSTOM_PROVIDER_PRESETS.map((preset) => [
preset.providerType,
preset.modelIdsPlaceholder,
]),
),
};
export function isCustomProviderType(
providerType: string | null | undefined,
): boolean {
if (!providerType) return false;
return providerType in CUSTOM_PROVIDER_LABELS;
}
export function customProviderDisplayName(
providerType: string | null | undefined,
): string {
if (!providerType) return "Custom";
return CUSTOM_PROVIDER_LABELS[providerType] ?? providerType;
}
export function customProviderBaseUrlPlaceholder(
providerType: string | null | undefined,
): string {
if (!providerType) {
return CUSTOM_PROVIDER_BASE_URL_PLACEHOLDERS[LEGACY_CUSTOM_PROVIDER_TYPE];
}
return (
CUSTOM_PROVIDER_BASE_URL_PLACEHOLDERS[providerType] ??
CUSTOM_PROVIDER_BASE_URL_PLACEHOLDERS[LEGACY_CUSTOM_PROVIDER_TYPE]
);
}
export function customProviderModelIdsPlaceholder(
providerType: string | null | undefined,
): string {
if (!providerType) {
return CUSTOM_PROVIDER_MODEL_IDS_PLACEHOLDERS[LEGACY_CUSTOM_PROVIDER_TYPE];
}
return (
CUSTOM_PROVIDER_MODEL_IDS_PLACEHOLDERS[providerType] ??
CUSTOM_PROVIDER_MODEL_IDS_PLACEHOLDERS[LEGACY_CUSTOM_PROVIDER_TYPE]
);
}
export function toExternalBackendProviderType(providerType: string): string;
export function toExternalBackendProviderType(
providerType: null | undefined,
): undefined;
export function toExternalBackendProviderType(
providerType: string | null | undefined,
): string | undefined;
export function toExternalBackendProviderType(
providerType: string | null | undefined,
): string | undefined {
if (!providerType) return undefined;
// vLLM's /v1/responses applies the loaded model's chat template, which
// 400s on strict-alternation templates (e.g. Gemma 3). Pass the actual
// type through so the backend routes vLLM to /v1/chat/completions instead
// of the OpenAI Responses path used for gpt-5.x.
if (providerType === "vllm") return "vllm";
return isCustomProviderType(providerType)
? CUSTOM_BACKEND_PROVIDER_TYPE
: providerType;
}
const EXTERNAL_PROVIDERS_KEY = "unsloth_chat_external_providers";
const EXTERNAL_PROVIDER_KEYS_KEY = "unsloth_chat_external_provider_keys";
const EXTERNAL_MODEL_PREFIX = "external::";
function canUseStorage(): boolean {
return typeof window !== "undefined";
}
export function isExternalModelId(
value: string | null | undefined,
): value is string {
return typeof value === "string" && value.startsWith(EXTERNAL_MODEL_PREFIX);
}
export function buildExternalModelId(providerId: string, modelId: string): string {
return `${EXTERNAL_MODEL_PREFIX}${providerId}::${encodeURIComponent(modelId)}`;
}
export function parseExternalModelId(
value: string | null | undefined,
): { providerId: string; modelId: string } | null {
if (!isExternalModelId(value)) return null;
const payload = value.slice(EXTERNAL_MODEL_PREFIX.length);
const separator = payload.indexOf("::");
if (separator < 0) return null;
const providerId = payload.slice(0, separator);
const encodedModelId = payload.slice(separator + 2);
if (!providerId || !encodedModelId) return null;
try {
return { providerId, modelId: decodeURIComponent(encodedModelId) };
} catch {
return null;
}
}
function isExternalProviderConfig(value: unknown): value is ExternalProviderConfig {
if (!value || typeof value !== "object") return false;
const maybe = value as Partial<ExternalProviderConfig>;
return (
typeof maybe.id === "string" &&
typeof maybe.providerType === "string" &&
typeof maybe.name === "string" &&
typeof maybe.baseUrl === "string" &&
Array.isArray(maybe.models)
);
}
function mapLegacyPresetToProviderType(presetId: string): string {
if (presetId === "google") return "gemini";
return presetId;
}
function normalizeProvider(raw: ExternalProviderConfig): ExternalProviderConfig {
const providerType = raw.providerType.trim();
return {
...raw,
providerType,
name: raw.name.trim(),
baseUrl: raw.baseUrl.trim(),
models: raw.models
.map((model) => model.trim())
.filter((model) => model.length > 0),
availableModels: (raw.availableModels ?? [])
.map((model) => model.trim())
.filter((model) => model.length > 0),
enablePromptCaching: supportsProviderPromptCaching(providerType)
? raw.enablePromptCaching !== false
: undefined,
isReasoningModel: supportsProviderReasoningToggle(providerType)
? raw.isReasoningModel === true
: undefined,
openaiContainerTtlMinutes:
providerType === "openai" &&
typeof raw.openaiContainerTtlMinutes === "number" &&
raw.openaiContainerTtlMinutes >= 1
? Math.min(raw.openaiContainerTtlMinutes, 20)
: undefined,
};
}
function isCompleteProvider(provider: ExternalProviderConfig): boolean {
if (!provider.id || !provider.name || !provider.providerType) return false;
return true;
}
type LegacyProviderConfig = {
id?: unknown;
presetId?: unknown;
name?: unknown;
baseUrl?: unknown;
models?: unknown;
createdAt?: unknown;
updatedAt?: unknown;
};
function fromUnknownProvider(value: unknown): ExternalProviderConfig | null {
if (!value || typeof value !== "object") return null;
if (isExternalProviderConfig(value)) {
return value;
}
const legacy = value as LegacyProviderConfig;
const id = typeof legacy.id === "string" ? legacy.id : "";
const presetId = typeof legacy.presetId === "string" ? legacy.presetId : "";
if (!id || !presetId || presetId === "custom") return null;
const providerType = mapLegacyPresetToProviderType(presetId);
if (!providerType) return null;
return {
id,
providerType,
name: typeof legacy.name === "string" ? legacy.name : providerType,
baseUrl: typeof legacy.baseUrl === "string" ? legacy.baseUrl : "",
models: Array.isArray(legacy.models)
? legacy.models.filter((item): item is string => typeof item === "string")
: [],
createdAt: typeof legacy.createdAt === "number" ? legacy.createdAt : Date.now(),
updatedAt: typeof legacy.updatedAt === "number" ? legacy.updatedAt : Date.now(),
};
}
export function loadExternalProviders(): ExternalProviderConfig[] {
if (!canUseStorage()) return [];
try {
const raw = localStorage.getItem(EXTERNAL_PROVIDERS_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed
.map(fromUnknownProvider)
.filter((provider): provider is ExternalProviderConfig => provider !== null)
.map(normalizeProvider)
.filter(isCompleteProvider);
} catch {
return [];
}
}
/**
* Load the raw (encrypted or legacy plaintext) key map from localStorage.
* Values are opaque strings either AES-GCM ciphertext or legacy plaintext.
*/
function loadRawKeyMap(): Record<string, string> {
if (!canUseStorage()) return {};
try {
const raw = localStorage.getItem(EXTERNAL_PROVIDER_KEYS_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const out: Record<string, string> = {};
for (const [providerId, value] of Object.entries(parsed)) {
if (typeof providerId === "string" && typeof value === "string") {
out[providerId] = value;
}
}
return out;
} catch {
return {};
}
}
function saveRawKeyMap(map: Record<string, string>): void {
if (!canUseStorage()) return;
try {
localStorage.setItem(EXTERNAL_PROVIDER_KEYS_KEY, JSON.stringify(map));
} catch {
// ignore
}
}
export function saveExternalProviders(
providers: ExternalProviderConfig[],
): void {
if (!canUseStorage()) return;
try {
localStorage.setItem(EXTERNAL_PROVIDERS_KEY, JSON.stringify(providers));
// Prune keys for removed providers — works on raw ciphertext, no decryption needed
const allowedIds = new Set(providers.map((provider) => provider.id));
const keys = loadRawKeyMap();
const pruned: Record<string, string> = {};
for (const [providerId, value] of Object.entries(keys)) {
if (allowedIds.has(providerId)) {
pruned[providerId] = value;
}
}
saveRawKeyMap(pruned);
} catch {
// ignore
}
}
/**
* Retrieve a provider API key from localStorage.
* Returns "" if no key is stored.
*/
export function getExternalProviderApiKey(
providerId: string,
): string {
const keys = loadRawKeyMap();
return keys[providerId] ?? "";
}
/**
* Store a provider API key in localStorage.
*/
export function setExternalProviderApiKey(
providerId: string,
apiKey: string,
): void {
if (!canUseStorage()) return;
const keys = loadRawKeyMap();
keys[providerId] = apiKey;
saveRawKeyMap(keys);
}
export function removeExternalProviderApiKey(providerId: string): void {
if (!canUseStorage()) return;
try {
const keys = loadRawKeyMap();
delete keys[providerId];
saveRawKeyMap(keys);
} catch {
// ignore
}
}

View file

@ -23,7 +23,10 @@ import {
validateModel,
} from "../api/chat-api";
import { formatEta, formatRate } from "../utils/format-transfer";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import {
type ReasoningEffort,
useChatRuntimeStore,
} from "../stores/chat-runtime-store";
import {
mergeBackendRecommendedInference,
resolveLoadMaxSeqLength,
@ -31,6 +34,7 @@ import {
import {
isMultimodalResponse,
} from "../types/api";
import { isExternalModelId } from "../external-providers";
import type {
ChatLoraSummary,
ChatModelSummary,
@ -143,6 +147,15 @@ function normalizeSpeculativeType(v: string | null | undefined): string | null {
return "default";
}
type LocalReasoningEffort = Extract<ReasoningEffort, "low" | "medium" | "high">;
function clampLocalReasoningEffort(value: ReasoningEffort): LocalReasoningEffort {
if (value === "low" || value === "medium" || value === "high") {
return value;
}
return "low";
}
export function useChatModelRuntime() {
const params = useChatRuntimeStore((state) => state.params);
const models = useChatRuntimeStore((state) => state.models);
@ -221,7 +234,9 @@ export function useChatModelRuntime() {
setModels(listRes.models.map(toChatModelSummary));
setLoras(lorasRes.loras.map(toLoraSummary));
if (statusRes.active_model) {
const selectedCheckpoint = useChatRuntimeStore.getState().params.checkpoint;
const isExternalSelectionActive = isExternalModelId(selectedCheckpoint);
if (statusRes.active_model && !isExternalSelectionActive) {
setCheckpoint(statusRes.active_model, statusRes.gguf_variant);
// Apply inference defaults on reconnect (page refresh with model already loaded)
@ -241,6 +256,10 @@ export function useChatModelRuntime() {
const supportsReasoning = statusRes.supports_reasoning ?? false;
const reasoningAlwaysOn = statusRes.reasoning_always_on ?? false;
const reasoningStyle = statusRes.reasoning_style ?? "enable_thinking";
const reasoningEffortLevels =
reasoningStyle === "reasoning_effort"
? (["low", "medium", "high"] as const)
: (["low", "medium", "high"] as const);
const supportsPreserveThinking = statusRes.supports_preserve_thinking ?? false;
const supportsTools = statusRes.supports_tools ?? false;
const currentGgufContextLength = statusRes.is_gguf
@ -262,6 +281,9 @@ export function useChatModelRuntime() {
// Otherwise we'd clobber the values the load path just applied and
// the UI would appear to revert the user's changes.
const prevState = useChatRuntimeStore.getState();
const clampedReasoningEffort = clampLocalReasoningEffort(
prevState.reasoningEffort,
);
const nextDefaultChatTemplate =
statusRes.chat_template === undefined
? prevState.defaultChatTemplate
@ -270,12 +292,25 @@ export function useChatModelRuntime() {
supportsReasoning,
reasoningAlwaysOn,
reasoningStyle,
supportsReasoningOff: reasoningStyle !== "reasoning_effort",
reasoningEffortLevels,
reasoningEffort: clampedReasoningEffort,
supportsPreserveThinking,
supportsTools,
// Reset per-turn reasoning flag so models that do not support
// reasoning do not inherit a stale off state from a prior model.
// Reset per-turn reasoning flag so:
// 1. models that do not support reasoning do not inherit a stale
// off state from a prior model, and
// 2. local reasoning-effort models (where the composer hides
// the Off option via supportsReasoningOff=false) cannot end
// up with reasoningEnabled=false carried over from an
// external model where Off was selected — the composer would
// keep showing "Think: <level>" via effectiveReasoningEnabled,
// but the chat-adapter would omit the kwarg and the Harmony
// template would fall back to its own default effort.
reasoningEnabled: supportsReasoning
? useChatRuntimeStore.getState().reasoningEnabled
? reasoningStyle === "reasoning_effort"
? true
: useChatRuntimeStore.getState().reasoningEnabled
: true,
ggufContextLength: currentGgufContextLength,
ggufMaxContextLength,
@ -313,7 +348,7 @@ export function useChatModelRuntime() {
}
useChatRuntimeStore.getState().setReasoningEnabled(reasoningDefault);
}
} else {
} else if (!statusRes.active_model && !isExternalSelectionActive) {
useChatRuntimeStore.setState({
modelRequiresTrustRemoteCode: false,
loadedIsMultimodal: false,
@ -569,6 +604,15 @@ export function useChatModelRuntime() {
// context state and display the backend-reported effective context.
const keepCustomCtx = null;
const reasoningAlwaysOn = loadResponse.reasoning_always_on ?? false;
const reasoningStyle = loadResponse.reasoning_style ?? "enable_thinking";
const reasoningEffortLevels =
reasoningStyle === "reasoning_effort"
? (["low", "medium", "high"] as const)
: (["low", "medium", "high"] as const);
const existingReasoningEffort = useChatRuntimeStore.getState().reasoningEffort;
const clampedReasoningEffort = clampLocalReasoningEffort(
existingReasoningEffort,
);
const ggufMaxContextLength = reportedMaxCtx;
useChatRuntimeStore.setState({
ggufContextLength: nativeCtx,
@ -579,7 +623,10 @@ export function useChatModelRuntime() {
supportsReasoning: loadResponse.supports_reasoning ?? false,
reasoningAlwaysOn,
reasoningEnabled: reasoningAlwaysOn ? true : reasoningDefault,
reasoningStyle: loadResponse.reasoning_style ?? "enable_thinking",
reasoningStyle,
supportsReasoningOff: reasoningStyle !== "reasoning_effort",
reasoningEffortLevels,
reasoningEffort: clampedReasoningEffort,
supportsPreserveThinking: loadResponse.supports_preserve_thinking ?? false,
supportsTools: loadResponse.supports_tools ?? false,
toolsEnabled: loadResponse.supports_tools ?? false,

View file

@ -0,0 +1,244 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* Friendly default names for auto-created OpenAI shell containers.
* Used by the chat-adapter when the lazy-create path fires (Code pill
* on, no thread container yet, user has set a non-default TTL). The
* goal is a human-memorable label like "otter" or "harbor" instead of
* "chat-abc12345" the user can still rename via the Studio-side
* alias map.
*
* The list is curated to:
* - Be unambiguous, non-offensive nouns from natural categories
* (animals, plants, geography, materials, weather).
* - Avoid technical / political / brand words that might read as
* odd in a chat UI.
* - Stay reasonably small so the bundle cost is negligible (~200
* entries × ~7 bytes 1.5 KB).
*
* Collisions are tolerated the container's real unique key is its
* ``cntr_*`` id, not its name. A short random hex suffix is appended
* to make accidental same-name collisions visually distinct in the
* picker list.
*/
const WORDS = [
// animals
"otter",
"falcon",
"heron",
"lynx",
"marten",
"stoat",
"raven",
"magpie",
"salmon",
"trout",
"perch",
"tortoise",
"gecko",
"iguana",
"axolotl",
"narwhal",
"manatee",
"dolphin",
"porpoise",
"octopus",
"cuttlefish",
"nautilus",
"starfish",
"urchin",
"anemone",
"coral",
"puffin",
"kestrel",
"osprey",
"buzzard",
"kingfisher",
"robin",
"wren",
"finch",
"sparrow",
"thrush",
"siskin",
"warbler",
"tanager",
"oriole",
"hare",
"badger",
"weasel",
"ferret",
"polecat",
"civet",
"tapir",
"okapi",
"ibex",
"chamois",
// plants & trees
"alder",
"aspen",
"birch",
"cedar",
"cypress",
"elder",
"elm",
"fir",
"ginkgo",
"hawthorn",
"hazel",
"hemlock",
"holly",
"juniper",
"larch",
"linden",
"maple",
"oak",
"olive",
"pine",
"rowan",
"spruce",
"sycamore",
"willow",
"yew",
"thistle",
"fern",
"moss",
"ivy",
"clover",
"heather",
"lavender",
"rosemary",
"sage",
"thyme",
"myrtle",
"laurel",
"magnolia",
// geography / landscape
"harbor",
"atoll",
"lagoon",
"estuary",
"fjord",
"delta",
"isthmus",
"mesa",
"plateau",
"valley",
"ridge",
"summit",
"glade",
"meadow",
"moor",
"heath",
"tundra",
"savanna",
"prairie",
"steppe",
"bayou",
"marsh",
"fen",
"grotto",
"cavern",
"canyon",
"ravine",
"gorge",
"knoll",
"dell",
"vale",
"coast",
// materials / minerals / colors
"amber",
"agate",
"onyx",
"opal",
"jade",
"quartz",
"obsidian",
"basalt",
"granite",
"marble",
"slate",
"flint",
"lapis",
"topaz",
"garnet",
"pearl",
"coral",
"ivory",
"ebony",
"copper",
"cobalt",
"indigo",
"saffron",
"vermilion",
"ochre",
"umber",
"sienna",
"russet",
// weather / sky / time
"aurora",
"comet",
"ember",
"frost",
"gale",
"harvest",
"monsoon",
"nebula",
"solstice",
"twilight",
"zephyr",
"drizzle",
"tempest",
"halcyon",
"equinox",
"rainbow",
"horizon",
"meridian",
"zenith",
"comet",
// misc tactile / cozy nouns
"lantern",
"kettle",
"compass",
"anchor",
"beacon",
"harbor",
"voyage",
"trellis",
"cottage",
"thicket",
"orchard",
"bramble",
"haystack",
"snowfall",
"campfire",
];
/** RFC 4122-ish 4-character lowercase hex suffix using crypto.randomUUID. */
function randomHexSuffix(): string {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID().replace(/-/g, "").slice(0, 4);
}
// Older browser fallback. Math.random is fine here — this is a
// display suffix, not a security token.
return Math.floor(Math.random() * 0xffff)
.toString(16)
.padStart(4, "0");
}
/**
* Returns a single English-word name with a short random hex suffix.
*
* Example output: "kestrel-3f9c", "harbor-a012".
*
* The suffix keeps containers visually distinguishable in the picker
* when the same word recurs across creations.
*/
export function pickFriendlyContainerName(): string {
const word = WORDS[Math.floor(Math.random() * WORDS.length)] ?? "container";
return `${word}-${randomHexSuffix()}`;
}

View file

@ -0,0 +1,598 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* Per-provider sampling parameter capability matrix.
*
* Values are derived from each provider's published chat-completion docs as of
* 2026-05. They describe which of our UI knobs map cleanly onto the provider's
* request body; the panel hides params a provider does not accept so users
* cannot dial a value that gets silently dropped or rejected.
*
* "Local" models (anything that is not an external provider) are represented by
* a null capability every knob renders for them.
*/
export interface ProviderCapabilities {
/**
* Temperature sampling. Reasoning-class models (OpenAI's gpt-5.x / o3 via
* /v1/responses) reject this with `Unsupported parameter`.
*/
temperature: boolean;
/** Nucleus (top_p) sampling. Same restriction as `temperature` on OpenAI. */
topP: boolean;
/** top-k token sampling (only Anthropic on the providers we ship). */
topK: boolean;
/** min-p token cutoff (no SaaS provider currently exposes this). */
minP: boolean;
/** Repetition penalty (no SaaS provider currently exposes this). */
repetitionPenalty: boolean;
/** OpenAI-style presence penalty. */
presencePenalty: boolean;
}
export type ExternalReasoningCapabilities = {
supportsReasoning: boolean;
reasoningStyle: "enable_thinking" | "reasoning_effort";
reasoningAlwaysOn: boolean;
supportsReasoningOff: boolean;
reasoningEffortLevels: readonly (
| "none"
| "minimal"
| "low"
| "medium"
| "high"
| "max"
| "xhigh"
)[];
};
/**
* Prefer a stored reasoning effort level that exists in ``effortLevels``,
* mapping legacy "xhigh" to "max" when the model only exposes the latter
* (Claude 4.6 adaptive thinking).
*/
export function clampReasoningEffortToLevels(
preferred: ExternalReasoningCapabilities["reasoningEffortLevels"][number],
effortLevels: ExternalReasoningCapabilities["reasoningEffortLevels"],
): ExternalReasoningCapabilities["reasoningEffortLevels"][number] {
let candidate = preferred;
if (
candidate === "xhigh" &&
!effortLevels.includes("xhigh") &&
effortLevels.includes("max")
) {
candidate = "max";
}
if (effortLevels.includes(candidate)) {
return candidate;
}
return effortLevels[0] ?? "low";
}
/**
* Output-token cap for any external provider request. Picked to stay below the
* tightest declared limit across the providers we ship (Anthropic Claude Opus
* tops out at 128k, GPT-5.x ~128k, Gemini 2.5 ~65k, DeepSeek 8k) while staying
* well above what a typical chat reply needs. The local-model path is not
* subject to this local backends honour whatever the loaded context allows.
*
* If a user's stored maxTokens (e.g. carried over from a prior local-model
* session with a 128k+ context) exceeds this, chat-adapter clamps the
* outbound request so the provider does not 400 on it.
*/
export const EXTERNAL_MAX_OUTPUT_TOKENS = 32768;
/**
* Whether the external provider offers a built-in web-search tool that the
* model invokes server-side. When `true`, the chat composer's Search button
* is available for that provider and the chat-adapter forwards
* `enable_tools: true, enabled_tools: ["web_search"]` on the request the
* backend routes the call through the provider's tool schema:
* - OpenAI: `tools: [{type: "web_search"}]` on /v1/responses
* - Anthropic: `tools: [{type: "web_search_20250305", name: "web_search",
* max_uses: 5}]` on /v1/messages
* - OpenRouter: `plugins: [{id: "web"}]` on /v1/chat/completions (the
* router's universal web-search shape; works for every
* underlying model including the `openrouter/free` router).
* - Kimi: `tools: [{type: "builtin_function", function: {name:
* "$web_search"}}]` with `thinking: {type:
* "disabled"}`. Requires a client round-trip:
* the first call returns the search args; the backend
* echoes them back as a role=tool message; the second
* call streams the answer. Handled in
* _stream_kimi_web_search on the backend.
*
* Mistral is intentionally excluded: their `web_search` connector lives on
* the Agents API (`/v1/agents` + `/v1/conversations`), not chat completions,
* and returns `"WebSearchTool connector is not supported"` if injected into
* /v1/chat/completions. Wiring it would require a dedicated Agents streaming
* path. Gemini's grounded-search can be added with the same pattern when
* matching backend translation lands.
*/
export function providerSupportsBuiltinWebSearch(
providerType: string | null | undefined,
): boolean {
return (
providerType === "openai" ||
providerType === "anthropic" ||
providerType === "openrouter" ||
providerType === "kimi"
);
}
/**
* Whether the selected external provider/model exposes a server-side
* code-execution tool. Two providers ship one today:
*
* - **Anthropic** (`code_execution_20250825`): Python + bash +
* str_replace-based file edits inside a 5 GB sandboxed container
* per request. Documented at
* https://platform.claude.com/docs/en/agents-and-tools/tool-use/code-execution-tool
*
* - **OpenAI cloud** (`shell` on /v1/responses): bash inside a
* reusable container; we auto-create one on the first turn of a
* chat thread and reference it on subsequent turns via the
* thread's stored `openaiCodeExecContainerId`. Documented at
* https://developers.openai.com/api/docs/guides/tools-shell
*
* Returns false for every other provider. The backend additionally
* gates the OpenAI shell tool on `is_openai_cloud` so custom
* OpenAI-compat servers (ollama / llama.cpp / vLLM) that also report
* `provider_type="openai"` never receive the tool but in practice
* none of those catalogs surface the `gpt-5.5` ids anyway, so the
* frontend prefix match is enough.
*
* v1 wires the tools themselves; file uploads (Anthropic
* `container_upload` / OpenAI `input_file`) are a deliberate follow-up.
*/
const ANTHROPIC_CODE_EXECUTION_MODEL_PREFIXES = [
"claude-opus-4-7",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-haiku-4-5",
// Deprecated upstream but the registry still exposes the ids, so the
// pill should remain functional for users on those snapshots.
"claude-opus-4-1",
"claude-opus-4",
"claude-sonnet-4",
] as const;
// OpenAI cloud shell-tool gating. Docs only explicitly demonstrate
// gpt-5.5; gpt-5.5-pro is included because the family share the same
// /v1/responses contract. `gpt-5.5-pro` is checked first so the prefix
// match doesn't collide with a hypothetical `gpt-5.5-turbo` etc.
const OPENAI_CODE_EXECUTION_MODEL_PREFIXES = [
"gpt-5.5-pro",
"gpt-5.5",
] as const;
/**
* Strict check that a provider configuration points at OpenAI's
* managed cloud (api.openai.com), as opposed to a custom OpenAI-compat
* backend (ollama / llama.cpp / vLLM / generic "custom" preset). The
* shell tool ONLY exists on OpenAI cloud; sending it to anything else
* 400s the request. Mirror of the backend's
* `is_openai_cloud = "api.openai.com" in self.base_url` guard.
*/
function isOpenAICloudBaseUrl(baseUrl: string | null | undefined): boolean {
if (!baseUrl) return true; // No override → uses the default openai.com base.
return baseUrl.trim().toLowerCase().includes("api.openai.com");
}
export function providerSupportsBuiltinCodeExecution(
providerType: string | null | undefined,
modelId: string | null | undefined,
baseUrl?: string | null,
): boolean {
const normalized = modelId?.trim().toLowerCase() ?? "";
if (!normalized) return false;
if (providerType === "anthropic") {
return ANTHROPIC_CODE_EXECUTION_MODEL_PREFIXES.some((prefix) =>
normalized.startsWith(prefix),
);
}
if (providerType === "openai") {
if (!isOpenAICloudBaseUrl(baseUrl)) return false;
return OPENAI_CODE_EXECUTION_MODEL_PREFIXES.some((prefix) =>
normalized.startsWith(prefix),
);
}
return false;
}
/**
* Per-provider minimum on the outbound max_tokens. Kimi's docs require
* `max_tokens >= 16000` whenever a thinking model is in use so the
* reasoning_content and final answer both fit in the budget anything
* lower truncates the response mid-stream. Other providers don't have a
* documented floor, so they fall through to the generic min of 64 in
* the slider.
*
* The chat-adapter resolves the effective floor on send and bumps the
* outbound max_tokens up to this value if the user's stored maxTokens
* sits below it. The settings panel reflects the same floor as the
* slider min so the displayed value never drifts from what's sent.
*/
const EXTERNAL_MIN_OUTPUT_TOKENS_BY_PROVIDER: Record<string, number> = {
kimi: 16000,
};
export function getExternalMinOutputTokens(
providerType: string | null | undefined,
): number {
if (!providerType) return 64;
return EXTERNAL_MIN_OUTPUT_TOKENS_BY_PROVIDER[providerType] ?? 64;
}
const OPENAI_COMPAT_BASE: ProviderCapabilities = {
temperature: true,
topP: true,
topK: false,
minP: false,
repetitionPenalty: false,
presencePenalty: true,
};
const ALL_SUPPORTED: ProviderCapabilities = {
temperature: true,
topP: true,
topK: true,
minP: true,
repetitionPenalty: true,
presencePenalty: true,
};
const PROVIDER_CAPABILITIES: Record<string, ProviderCapabilities> = {
// OpenAI's flagship models (gpt-5.x / o3 / gpt-4.5) are reasoning-class
// models served via /v1/responses, which rejects temperature, top_p, and
// presence/frequency penalty. See backend
// external_provider._stream_openai_responses for the proxy.
openai: {
temperature: false,
topP: false,
topK: false,
minP: false,
repetitionPenalty: false,
presencePenalty: false,
},
// Anthropic's Messages API accepts top_k on 3.x and 4.5/4.6, but Claude
// 4.7 (Opus/Sonnet/Haiku) deprecated it and returns 400 if it is set.
// We surface top_k in the panel for all Anthropic providers and let the
// backend strip it per-model — see _stream_anthropic in
// studio/backend/core/inference/external_provider.py.
// Presence/frequency penalty is not part of the Messages API on any
// Claude generation.
anthropic: {
temperature: true,
topP: true,
topK: true,
minP: false,
repetitionPenalty: false,
presencePenalty: false,
},
mistral: OPENAI_COMPAT_BASE,
gemini: OPENAI_COMPAT_BASE,
// Kimi k2.5/k2.6 are reasoning-class — the API locks temperature and
// top_p to fixed defaults and 400s on any other value:
// "invalid temperature: only 1 is allowed for this model".
// Hide both sliders so the user is not offered knobs the model
// silently overrides. Backend additionally strips these fields via
// PROVIDER_REGISTRY['kimi']['body_omit'].
kimi: {
temperature: false,
topP: false,
topK: false,
minP: false,
repetitionPenalty: false,
presencePenalty: true,
},
// DeepSeek deprecated presence/frequency penalty in their current docs.
deepseek: {
temperature: true,
topP: true,
topK: false,
minP: false,
repetitionPenalty: false,
presencePenalty: false,
},
qwen: OPENAI_COMPAT_BASE,
huggingface: OPENAI_COMPAT_BASE,
// OpenRouter silently drops params the target model does not support, so we
// surface every knob and let the gateway handle the per-model fan-out.
openrouter: ALL_SUPPORTED,
// Local OpenAI-compatible connections are proxied through the OpenAI backend
// path, but vLLM/Ollama/llama.cpp users often want top_k / min_p /
// repetition controls, so be permissive.
custom: ALL_SUPPORTED,
vllm: ALL_SUPPORTED,
ollama: ALL_SUPPORTED,
llama_cpp: ALL_SUPPORTED,
};
const DEFAULT_EXTERNAL_CAPABILITIES = OPENAI_COMPAT_BASE;
/**
* Resolve the capability set for an external provider. Returns `null` for
* a local model (i.e. when `providerType` is null/undefined), which callers
* should treat as "every knob applies".
*/
export function getProviderCapabilities(
providerType: string | null | undefined,
): ProviderCapabilities | null {
if (!providerType) return null;
return PROVIDER_CAPABILITIES[providerType] ?? DEFAULT_EXTERNAL_CAPABILITIES;
}
const DEFAULT_EFFORT_LEVELS = ["low", "medium", "high"] as const;
const OPENROUTER_MANDATORY_REASONING_MODELS = new Set([
"google/gemini-pro-latest",
"baidu/cobuddy:free",
"inclusionai/ring-2.6-1t:free",
"deepseek/deepseek-r1",
]);
function isOpenRouterMandatoryReasoningModel(modelId: string): boolean {
const normalized = modelId.trim().toLowerCase();
const canonical = normalized.startsWith("~") ? normalized.slice(1) : normalized;
return OPENROUTER_MANDATORY_REASONING_MODELS.has(canonical);
}
type ReasoningCaps = {
supportsReasoning: boolean;
supportsReasoningOff: boolean;
reasoningEffortLevels: ExternalReasoningCapabilities["reasoningEffortLevels"];
};
const DEFAULT_EXTERNAL_REASONING_CAPABILITIES: ExternalReasoningCapabilities = {
supportsReasoning: false,
reasoningStyle: "enable_thinking",
reasoningAlwaysOn: false,
supportsReasoningOff: false,
reasoningEffortLevels: DEFAULT_EFFORT_LEVELS,
};
const NO_REASONING_CAPS: ReasoningCaps = {
supportsReasoning: false,
supportsReasoningOff: false,
reasoningEffortLevels: DEFAULT_EFFORT_LEVELS,
};
const ANTHROPIC_REASONING_MODELS = [
{
prefixes: ["claude-opus-4-7"],
levels: ["none", "low", "medium", "high", "xhigh", "max"],
},
{
prefixes: ["claude-opus-4-6", "claude-sonnet-4-6"],
levels: ["none", "low", "medium", "high", "max"],
},
{
prefixes: ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"],
// Backend maps semantic levels to manual budget_tokens.
levels: ["none", "low", "medium", "high"],
},
] as const;
function matchesModelPrefix(
modelId: string,
prefixes: readonly string[],
): boolean {
return prefixes.some((prefix) => modelId.startsWith(prefix));
}
function resolveAnthropicReasoningEffortCapabilities(modelId: string): ReasoningCaps {
const normalized = modelId.trim().toLowerCase();
const matched = ANTHROPIC_REASONING_MODELS.find((entry) =>
matchesModelPrefix(normalized, entry.prefixes),
);
if (matched) {
return {
supportsReasoning: true,
supportsReasoningOff: true,
reasoningEffortLevels: matched.levels,
};
}
return NO_REASONING_CAPS;
}
const OPENAI_REASONING_MODELS = [
{
prefixes: ["gpt-5.5-pro", "gpt-5.4-pro"],
supportsOff: false,
levels: ["medium", "high", "xhigh"],
},
{
prefixes: ["gpt-5.5", "gpt-5.4"],
supportsOff: true,
levels: ["none", "low", "medium", "high", "xhigh"],
},
{
prefixes: ["gpt-5.3-chat-latest"],
supportsOff: false,
levels: ["medium"],
},
{
prefixes: ["gpt-5.3-codex"],
supportsOff: true,
levels: ["none", "low", "medium", "high", "xhigh"],
},
{
prefixes: ["gpt-5", "gpt-5.1", "gpt-5.2"],
supportsOff: false,
levels: ["minimal", "low", "medium", "high"],
},
{
prefixes: ["o3"],
supportsOff: false,
levels: DEFAULT_EFFORT_LEVELS,
},
] as const;
function resolveOpenAIReasoningEffortCapabilities(modelId: string): ReasoningCaps {
const normalized = modelId.trim().toLowerCase();
const matched = OPENAI_REASONING_MODELS.find((entry) =>
matchesModelPrefix(normalized, entry.prefixes),
);
if (matched) {
return {
supportsReasoning: true,
supportsReasoningOff: matched.supportsOff,
reasoningEffortLevels: matched.levels,
};
}
return NO_REASONING_CAPS;
}
function withEnableThinkingStyle(
overrides?: Partial<ExternalReasoningCapabilities>,
): ExternalReasoningCapabilities {
return {
...DEFAULT_EXTERNAL_REASONING_CAPABILITIES,
...overrides,
reasoningStyle: "enable_thinking",
};
}
function withReasoningEffortStyle(caps: ReasoningCaps): ExternalReasoningCapabilities {
return {
...DEFAULT_EXTERNAL_REASONING_CAPABILITIES,
supportsReasoning: true,
reasoningStyle: "reasoning_effort",
supportsReasoningOff: caps.supportsReasoningOff,
reasoningEffortLevels: caps.reasoningEffortLevels,
};
}
function resolveKimiReasoningCapabilities(modelId: string): ExternalReasoningCapabilities {
// Kimi exposes a boolean thinking toggle rather than an effort scale.
// - kimi-k2.6: thinking enabled by default, toggleable
// via extra_body: {thinking: {type: enabled|disabled}}
// - kimi-k2-thinking: thinking always on, no off switch
// - kimi-k2.5 (and anything else): no thinking
if (modelId === "kimi-k2-thinking") {
return withEnableThinkingStyle({
supportsReasoning: true,
reasoningAlwaysOn: true,
});
}
if (modelId === "kimi-k2.6") {
return withEnableThinkingStyle({
supportsReasoning: true,
supportsReasoningOff: true,
});
}
return withEnableThinkingStyle();
}
function resolveMistralReasoningCapabilities(modelId: string): ExternalReasoningCapabilities {
if (modelId === "magistral-medium-latest") {
return withReasoningEffortStyle({
supportsReasoning: true,
supportsReasoningOff: false,
// Native reasoning model: present baseline as Medium in the UI.
reasoningEffortLevels: ["medium", "high"] as const,
});
}
if (modelId === "mistral-small-latest" || modelId === "mistral-vibe-cli-latest") {
return withReasoningEffortStyle({
supportsReasoning: true,
supportsReasoningOff: true,
reasoningEffortLevels: ["none", "high"] as const,
});
}
return withEnableThinkingStyle();
}
export interface ExternalReasoningResolveOptions {
/** vLLM connection flagged as a reasoning model in provider config. */
isReasoningProvider?: boolean;
}
// vLLM has no per-model reasoning signal on OpenAI-compat — pin via user toggle.
function resolveConnectionLevelReasoning(
normalizedProvider: string,
options: ExternalReasoningResolveOptions | undefined,
): ExternalReasoningCapabilities | null {
if (normalizedProvider === "vllm" && options?.isReasoningProvider) {
return withEnableThinkingStyle({
supportsReasoning: true,
supportsReasoningOff: true,
});
}
return null;
}
/**
* resolve external-model thinking capabilities.
* provider-specific matching lives in the OpenAI/Anthropic resolvers.
* other providers default to no reasoning controls.
*/
export function getExternalReasoningCapabilities(
providerType: string | null | undefined,
modelId: string | null | undefined,
options?: ExternalReasoningResolveOptions,
): ExternalReasoningCapabilities {
const normalizedModel = modelId?.trim().toLowerCase() ?? "";
const normalizedProvider = providerType?.trim().toLowerCase() ?? "";
const connectionLevel = resolveConnectionLevelReasoning(
normalizedProvider,
options,
);
if (connectionLevel) {
return connectionLevel;
}
if (!normalizedModel) {
return withEnableThinkingStyle();
}
// Some OpenRouter-routed ids are mandatory-reasoning and must stay on even
// if they arrive through aliased/custom provider routes.
if (isOpenRouterMandatoryReasoningModel(normalizedModel)) {
return withEnableThinkingStyle({
supportsReasoning: true,
reasoningAlwaysOn: true,
supportsReasoningOff: false,
});
}
// OpenRouter ids are namespaced (e.g. "openai/gpt-5.5").
const modelForMatching =
normalizedProvider === "openrouter" && normalizedModel.includes("/")
? normalizedModel.split("/").at(-1) ?? normalizedModel
: normalizedModel;
const isOpenAIProvider = normalizedProvider === "openai";
const isAnthropicProvider = normalizedProvider === "anthropic";
const isKimiProvider = normalizedProvider === "kimi";
const isMistralProvider = normalizedProvider === "mistral";
const isOpenRouterProvider = normalizedProvider === "openrouter";
if (isOpenRouterProvider) {
// OpenRouter's unified `reasoning` parameter is accepted on every
// chat-completion request; the gateway silently no-ops for models
// that don't reason. Mandatory-reasoning ids are handled by the
// early guard above; everything else exposes a toggleable control.
return {
supportsReasoning: true,
reasoningStyle: "enable_thinking",
reasoningAlwaysOn: false,
supportsReasoningOff: true,
reasoningEffortLevels: DEFAULT_EFFORT_LEVELS,
};
}
if (isKimiProvider) return resolveKimiReasoningCapabilities(modelForMatching);
if (isMistralProvider) return resolveMistralReasoningCapabilities(modelForMatching);
if (!isOpenAIProvider && !isAnthropicProvider) {
return withEnableThinkingStyle();
}
const providerCaps = isOpenAIProvider
? resolveOpenAIReasoningEffortCapabilities(modelForMatching)
: resolveAnthropicReasoningEffortCapabilities(modelForMatching);
if (providerCaps.supportsReasoning) {
return withReasoningEffortStyle(providerCaps);
}
return withEnableThinkingStyle();
}

View file

@ -396,7 +396,7 @@ function toThreadMessage(m: MessageRecord): ThreadMessage {
};
}
async function ensureThreadRecord({
export async function ensureThreadRecord({
threadId,
modelType,
pairId,

View file

@ -18,7 +18,16 @@ import { useAui } from "@assistant-ui/react";
import { ArrowUpIcon, GlobeIcon, HeadphonesIcon, LightbulbIcon, LightbulbOffIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { loadModel, validateModel } from "./api/chat-api";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import { parseExternalModelId } from "./external-providers";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import {
type ReasoningEffort,
useChatRuntimeStore,
} from "./stores/chat-runtime-store";
import {
getExternalReasoningCapabilities,
providerSupportsBuiltinCodeExecution,
} from "./provider-capabilities";
import {
type CompositionEvent,
type KeyboardEvent,
@ -66,6 +75,33 @@ function fileToBase64DataURL(file: File): Promise<string> {
});
}
function formatReasoningEffortLabel(level: ReasoningEffort, modelId?: string): string {
if (level === "max") return "Max";
if (level === "xhigh") {
const normalized = modelId?.trim().toLowerCase() ?? "";
if (
normalized.startsWith("claude-opus-4-6") ||
normalized.startsWith("claude-sonnet-4-6")
) {
return "Max";
}
return "Extra High";
}
return level.charAt(0).toUpperCase() + level.slice(1);
}
function formatReasoningDisabledLabel(
supportsReasoningOff: boolean,
isExternalOpenAIReasoning: boolean,
modelId?: string,
): string {
const normalized = modelId?.trim().toLowerCase() ?? "";
// Magistral keeps the "none" wire value, but UX should present this floor
// as "Medium" rather than a disabled state label.
if (normalized.includes("magistral-medium-latest")) return "Medium";
return supportsReasoningOff && isExternalOpenAIReasoning ? "None" : "Off";
}
function useDictation(
setText: (value: string | ((prev: string) => string)) => void,
) {
@ -253,6 +289,8 @@ export function SharedComposer({
const checkpoint = s.params.checkpoint;
return s.models.find((m) => m.id === checkpoint);
});
const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
const externalProviders = useExternalProvidersStore((s) => s.providers);
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
);
@ -262,17 +300,94 @@ export function SharedComposer({
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 supportsPreserveThinking = useChatRuntimeStore((s) => s.supportsPreserveThinking);
const preserveThinking = useChatRuntimeStore((s) => s.preserveThinking);
const setPreserveThinking = useChatRuntimeStore((s) => s.setPreserveThinking);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
const supportsBuiltinWebSearch = useChatRuntimeStore(
(s) => s.supportsBuiltinWebSearch,
);
const toolsEnabled = useChatRuntimeStore((s) => s.toolsEnabled);
const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled);
const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled);
const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled);
const reasoningDisabled = !modelLoaded || !supportsReasoning;
const toolsDisabled = !modelLoaded || !supportsTools;
const lastOpenRouterChosenModel = useChatRuntimeStore(
(s) => s.lastOpenRouterChosenModel,
);
const externalSelection = parseExternalModelId(checkpoint);
const selectedExternalProvider =
externalSelection != null
? externalProviders.find((p) => p.id === externalSelection.providerId)
: undefined;
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 isExternalOpenAIReasoning =
externalReasoningCaps?.supportsReasoning === true &&
externalReasoningCaps.reasoningStyle === "reasoning_effort";
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);
// Kimi's $web_search builtin mandates thinking=disabled per the docs at
// https://platform.kimi.ai/docs/guide/use-web-search. Both pills stay
// clickable for Kimi, but turning one on flips the other off — the
// click handlers below enforce this mutual exclusion so the visible
// state always matches what the backend actually sends.
const isKimiExternal = selectedExternalProvider?.providerType === "kimi";
const effectiveReasoningEnabled = reasoningLockedOn ? true : reasoningEnabled;
const effectiveReasoningVisualEnabled =
effectiveReasoningEnabled && reasoningEffort !== "none";
const reasoningDisabled = !modelLoaded || !effectiveSupportsReasoning;
const showReasoningControl =
effectiveSupportsReasoning || effectiveReasoningAlwaysOn;
// Two-pill gating: Search pill lights up when the runtime has either
// a local tool runtime (supportsTools, gives us our Code/python + local
// web_search) OR a server-side web_search the provider runs for us
// (supportsBuiltinWebSearch, currently OpenAI / Anthropic / OpenRouter
// / Kimi). Code pill lights up on the local runtime OR when Anthropic
// is selected with a model that accepts the server-side
// code_execution_20250825 tool — see
// providerSupportsBuiltinCodeExecution. Anthropic is the only external
// provider that ships a code-execution tool today.
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
selectedExternalProvider?.providerType,
effectiveExternalModelId,
selectedExternalProvider?.baseUrl,
);
const searchDisabled =
!modelLoaded || !(supportsTools || supportsBuiltinWebSearch);
const codeDisabled =
!modelLoaded || !(supportsTools || supportsBuiltinCodeExecution);
// Backwards-compatible alias for any other call site that may still
// reference `toolsDisabled` (rare; both pills used it before).
const toolsDisabled = codeDisabled;
const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio);
const clearPendingAudioStore = useChatRuntimeStore((s) => s.clearPendingAudio);
@ -625,7 +740,8 @@ export function SharedComposer({
</TooltipIconButton>
</>
)}
{reasoningStyle === "reasoning_effort" ? (
{showReasoningControl ? (
effectiveReasoningStyle === "reasoning_effort" ? (
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button
@ -635,26 +751,66 @@ export function SharedComposer({
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
reasoningDisabled
? "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}`}
>
<LightbulbIcon className="size-3.5" />
{effectiveReasoningVisualEnabled ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>
Think:{" "}
{reasoningEffort.charAt(0).toUpperCase() +
reasoningEffort.slice(1)}
{effectiveReasoningVisualEnabled
? formatReasoningEffortLabel(
reasoningEffort,
externalSelection?.modelId,
)
: formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(["low", "medium", "high"] as const).map((level) => (
{effectiveSupportsReasoningOff && (
<DropdownMenuItem
onSelect={() => {
setReasoningEnabled(false);
applyQwenThinkingParams(false);
}}
>
{formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
{!effectiveReasoningVisualEnabled ? " \u2713" : ""}
</DropdownMenuItem>
)}
{effectiveReasoningEffortLevels
.filter((level) => level !== "none")
.map((level) => (
<DropdownMenuItem
key={level}
onSelect={() => setReasoningEffort(level)}
onSelect={() => {
setReasoningEffort(level);
setReasoningEnabled(true);
applyQwenThinkingParams(true);
// Mutual exclusion: turning thinking on for a
// Kimi model forces the web_search builtin off.
if (isKimiExternal && toolsEnabled) {
setToolsEnabled(false);
}
}}
>
{level.charAt(0).toUpperCase() + level.slice(1)}
{reasoningEffort === level ? " \u2713" : ""}
{formatReasoningEffortLabel(level, externalSelection?.modelId)}
{effectiveReasoningVisualEnabled && reasoningEffort === level ? " \u2713" : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@ -662,31 +818,53 @@ export function SharedComposer({
) : (
<button
type="button"
disabled={reasoningDisabled}
disabled={reasoningDisabled || reasoningLockedOn}
aria-disabled={reasoningDisabled || reasoningLockedOn}
title={
reasoningLockedOn
? "This model requires reasoning to stay on."
: undefined
}
onClick={() => {
if (reasoningAlwaysOn) return;
if (reasoningLockedOn) return;
const next = !reasoningEnabled;
setReasoningEnabled(next);
applyQwenThinkingParams(next);
// Mutual exclusion: Kimi's $web_search builtin
// requires thinking off, so turning thinking on flips
// the Search pill off (and vice versa).
if (isKimiExternal && next && toolsEnabled) {
setToolsEnabled(false);
}
}}
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
reasoningDisabled
? "cursor-not-allowed opacity-40"
: (reasoningEnabled || reasoningAlwaysOn)
? "bg-primary/10 text-primary hover:bg-primary/20"
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
reasoningLockedOn
? "cursor-not-allowed bg-primary/10 text-primary"
: reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningEnabled
? "bg-primary/10 text-primary hover:bg-primary/20"
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
)}
aria-label={reasoningEnabled ? "Disable thinking" : "Enable thinking"}
aria-label={
reasoningLockedOn
? "Thinking is required for this model"
: effectiveReasoningEnabled
? "Disable thinking"
: "Enable thinking"
}
>
{(reasoningEnabled || reasoningAlwaysOn) && !reasoningDisabled ? (
{reasoningLockedOn ||
(effectiveReasoningEnabled && !reasoningDisabled) ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>Think</span>
</button>
)}
)
) : null}
{supportsPreserveThinking && (
<button
type="button"
@ -714,10 +892,22 @@ export function SharedComposer({
)}
<button
type="button"
disabled={toolsDisabled}
onClick={() => setToolsEnabled(!toolsEnabled)}
disabled={searchDisabled}
onClick={() => {
const next = !toolsEnabled;
setToolsEnabled(next);
// Kimi's $web_search builtin requires thinking=disabled
// (https://platform.kimi.ai/docs/guide/use-web-search).
// Toggle the Think pill off when Search comes on, and
// back on when Search goes off — mutual exclusion that
// mirrors what the backend enforces.
if (isKimiExternal) {
setReasoningEnabled(!next);
applyQwenThinkingParams(!next);
}
}}
className="composer-pill-btn"
data-active={toolsEnabled && !toolsDisabled ? "true" : "false"}
data-active={toolsEnabled && !searchDisabled ? "true" : "false"}
aria-label={toolsEnabled ? "Disable web search" : "Enable web search"}
>
<GlobeIcon className="size-3.5" />
@ -725,10 +915,10 @@ export function SharedComposer({
</button>
<button
type="button"
disabled={toolsDisabled}
disabled={codeDisabled}
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
className="composer-pill-btn"
data-active={codeToolsEnabled && !toolsDisabled ? "true" : "false"}
data-active={codeToolsEnabled && !codeDisabled ? "true" : "false"}
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
>
<CodeToggleIcon className="size-3.5" />

Some files were not shown because too many files have changed in this diff Show more