Merge commit 'dfba4cc5ca' into studio-tokenless-llama-prebuilt

# Conflicts:
#	tests/studio/install/test_macos_version_compat.py
This commit is contained in:
Daniel Han 2026-06-01 06:51:33 +00:00
commit 859fc42880
55 changed files with 4354 additions and 754 deletions

View file

@ -72,6 +72,31 @@ concurrency:
permissions:
contents: read
# ──────────────────────────────────────────────────────────────────────
# Network-resilience knobs, applied to every job/step. These add retries
# and backoff ONLY; they do not relax a single integrity check. cargo
# still resolves against Cargo.lock (--locked), pip still verifies the
# wheels it downloads, npm still enforces package-lock integrity, the
# harden-runner egress allowlists below are unchanged, and every action
# stays SHA-pinned. The advisory-audit run on 2026-05-29 red-failed when
# one crates.io tarball fetch hit "Recv failure: Connection reset by
# peer" (curl 56); cargo's default of 3 retries over an HTTP/2-multiplexed
# connection did not recover. The settings below make that class of
# transient fault self-heal instead of failing the whole run.
env:
# pip: raise the built-in retry count and per-connection timeout.
PIP_RETRIES: "10"
PIP_DEFAULT_TIMEOUT: "60"
# cargo: retry network ops and disable HTTP/2 multiplexing -- the
# documented mitigation for the curl-56 connection resets above.
CARGO_NET_RETRY: "10"
CARGO_HTTP_MULTIPLEXING: "false"
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
# npm: retry registry fetches with capped exponential backoff.
NPM_CONFIG_FETCH_RETRIES: "5"
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "2000"
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "60000"
jobs:
# ─────────────────────────────────────────────────────────────────────
# Combined advisory-DB audit: pip-audit + npm audit + cargo audit
@ -140,7 +165,7 @@ jobs:
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27
- uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1
- uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: studio/src-tauri -> target
@ -153,8 +178,23 @@ jobs:
# crashes with a TOML parse error on that file.
# npm audit is bundled with the node toolchain, no install.
run: |
python -m pip install --upgrade pip 'pip-audit>=2.7'
cargo install --locked --version '^0.22' cargo-audit
retry() { # retry <max-attempts> <command...> with exponential backoff
local max="$1"; shift
local n=1 delay=5
until "$@"; do
if [ "$n" -ge "$max" ]; then
echo "::error::command failed after ${n} attempts: $*" >&2
return 1
fi
echo "attempt ${n}/${max} failed; retrying in ${delay}s: $*" >&2
sleep "$delay"; n=$((n + 1)); delay=$((delay * 2))
done
}
retry 5 python -m pip install --upgrade pip 'pip-audit>=2.7'
# --locked keeps the resolved tree identical to Cargo.lock; the
# CARGO_NET_* env above plus this outer loop survive transient
# crates.io connection resets without weakening that guarantee.
retry 5 cargo install --locked --version '^0.22' cargo-audit
# ─────────────────────────────────────────────────────────────
# Python: pip-audit
@ -330,32 +370,60 @@ jobs:
# ─────────────────────────────────────────────────────────────
# OSV-Scanner: cross-ecosystem advisory DB (PyPI + npm + cargo)
# ─────────────────────────────────────────────────────────────
- name: Download + verify OSV-Scanner
# Split out from the scan below so binary integrity is a HARD gate:
# a checksum mismatch (swapped release asset, the Trivy-style pivot
# this workflow refuses) fails the job instead of being swallowed by
# the scan step's continue-on-error. A download still failing after
# retries is transient, so we skip the scan rather than red-fail.
# SHA-256 verified BEFORE chmod +x / exec. Bump OSV_SHA256 in lockstep
# with OSV_VERSION (value from the release's osv-scanner_SHA256SUMS).
run: |
set -euo pipefail
OSV_VERSION="v2.0.2"
OSV_SHA256="3abcfd7126c453a00421487e721b296e0cb68085bd431d6cef60872774170fc8"
if ! curl --proto '=https' --tlsv1.2 -fsSL \
--retry 5 --retry-delay 3 --retry-connrefused --retry-all-errors \
-o /tmp/osv-scanner \
"https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64"; then
echo "::warning::osv-scanner download failed after retries; skipping scan" >&2
rm -f /tmp/osv-scanner
exit 0 # transient availability: do not red-fail the job
fi
if ! echo "${OSV_SHA256} /tmp/osv-scanner" | sha256sum -c -; then
echo "::error::osv-scanner checksum mismatch; refusing to execute" >&2
rm -f /tmp/osv-scanner
exit 1 # integrity failure: hard-fail
fi
chmod +x /tmp/osv-scanner
/tmp/osv-scanner --version
- name: OSV-Scanner (PyPI + npm + cargo, cross-ecosystem advisories)
# OSV's advisory feed is a superset of GitHub-Advisory + RustSec
# + npm advisories; running it alongside the per-ecosystem audit
# tools catches CVEs that haven't propagated to the per-ecosystem
# DBs yet (e.g. langchain-core CVE-2025-68664 was on OSV before
# GitHub Advisory). Single binary, one transitive resolver, all
# three lockfile types in one pass. Non-blocking until baselines
# close.
# three lockfile types in one pass. Binary is checksum-verified in
# the step above; only the advisory scan stays non-blocking until
# baselines close.
continue-on-error: true
run: |
set +e
# OSV-Scanner ships a raw binary (no tarball) in v2.x.
curl -fsSL -o /tmp/osv-scanner \
https://github.com/google/osv-scanner/releases/download/v2.0.2/osv-scanner_linux_amd64
chmod +x /tmp/osv-scanner
/tmp/osv-scanner --version
/tmp/osv-scanner scan source \
--lockfile=studio/frontend/package-lock.json \
--lockfile=studio/src-tauri/Cargo.lock \
--lockfile=requirements.txt:audit-reqs/unsloth-deps.txt \
--lockfile=requirements.txt:audit-reqs/studio.txt \
--lockfile=requirements.txt:audit-reqs/no-torch-runtime.txt \
--lockfile=requirements.txt:audit-reqs/overrides.txt \
--lockfile=requirements.txt:audit-reqs/extras.txt \
--lockfile=requirements.txt:audit-reqs/extras-no-deps.txt \
--format=table 2>&1 | tee logs-osv-scanner.txt
if [ ! -x /tmp/osv-scanner ]; then
echo "osv-scanner unavailable this run; skipping scan" | tee logs-osv-scanner.txt
else
/tmp/osv-scanner scan source \
--lockfile=studio/frontend/package-lock.json \
--lockfile=studio/src-tauri/Cargo.lock \
--lockfile=requirements.txt:audit-reqs/unsloth-deps.txt \
--lockfile=requirements.txt:audit-reqs/studio.txt \
--lockfile=requirements.txt:audit-reqs/no-torch-runtime.txt \
--lockfile=requirements.txt:audit-reqs/overrides.txt \
--lockfile=requirements.txt:audit-reqs/extras.txt \
--lockfile=requirements.txt:audit-reqs/extras-no-deps.txt \
--format=table 2>&1 | tee logs-osv-scanner.txt
fi
{
echo "## OSV-Scanner (cross-ecosystem)"
echo
@ -1075,7 +1143,23 @@ jobs:
# new-install-script gate below protects against, and we must
# not run any third-party hook to set up the audit.
working-directory: studio/frontend
run: npm ci --ignore-scripts
run: |
retry() { # retry <max-attempts> <command...> with exponential backoff
local max="$1"; shift
local n=1 delay=5
until "$@"; do
if [ "$n" -ge "$max" ]; then
echo "::error::command failed after ${n} attempts: $*" >&2
return 1
fi
echo "attempt ${n}/${max} failed; retrying in ${delay}s: $*" >&2
sleep "$delay"; n=$((n + 1)); delay=$((delay * 2))
done
}
# --ignore-scripts is mandatory here (no third-party hook runs);
# the retry only re-attempts the registry fetch, it never relaxes
# that flag or the package-lock integrity check npm ci enforces.
retry 5 npm ci --ignore-scripts
- name: npm audit signatures (informational)
# Surfaces unsigned / mis-signed packages from the npm

View file

@ -72,10 +72,13 @@ Unsloth Studio (Beta) works on **Windows, Linux, WSL** and **macOS**.
```bash
curl -fsSL https://unsloth.ai/install.sh | sh
```
Use the same command to update.
#### Windows:
```powershell
irm https://unsloth.ai/install.ps1 | iex
```
Use the same command to update.
#### Launch
```bash
@ -83,9 +86,6 @@ unsloth studio -p 8888
```
For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally.
#### Update
To update, use the same install commands above or use `unsloth studio update`.
#### Docker
Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Run:
```bash
@ -171,7 +171,9 @@ unsloth studio -p 8888
```
Then to update :
```bash
unsloth studio update
cd unsloth && git pull
./install.sh --local
unsloth studio -p 8888
```
#### Developer installs: Windows PowerShell:
@ -184,7 +186,9 @@ unsloth studio -p 8888
```
Then to update :
```bash
unsloth studio update
cd unsloth && git pull
./install.sh --local
unsloth studio -p 8888
```
#### Nightly: MacOS, Linux, WSL:

View file

@ -1566,7 +1566,7 @@ shell.Run cmd, 0, False
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.8" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.9" unsloth-zoo }
if ($baseInstallExit -eq 0) {
# Resolve pydantic WITH deps so pip pins pydantic-core
# to the matching version (no-torch-runtime.txt below
@ -1580,7 +1580,7 @@ shell.Run cmd, 0, False
}
}
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.8" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.9" unsloth-zoo }
}
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
@ -1627,7 +1627,7 @@ shell.Run cmd, 0, False
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.8" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.9" unsloth-zoo }
if ($baseInstallExit -eq 0) {
# Same pydantic-with-deps trick as the migrated branch.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython pydantic }
@ -1639,7 +1639,7 @@ shell.Run cmd, 0, False
}
}
} elseif ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.8" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.9" unsloth-zoo }
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
}
@ -1667,7 +1667,7 @@ shell.Run cmd, 0, False
Write-TauriLog "STEP" "Installing unsloth"
substep "installing unsloth (this may take a few minutes)..."
if ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.8" --torch-backend=auto }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.9" --torch-backend=auto }
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)

View file

@ -1530,17 +1530,51 @@ if [ -x "$VENV_DIR/bin/python" ]; then
: > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true
fi
# Guard against Python 3.13.8 torch import bug on Apple Silicon
# (skip when the user explicitly chose a version via --python)
# Guard against two independent Apple Silicon venv problems, in order:
# 1. uv may create the venv from a cached x86_64 (Rosetta) Python when a
# same-version x86_64 build is already cached (often because uv itself
# is an x86_64 build). That venv reports x86_64 to wheel resolvers, and
# PyTorch ships no macOS wheels on the CPU index for any architecture,
# so the torch install can never resolve. Recreate it with an
# arch-explicit arm64 CPython.
# 2. Python 3.13.8 has a known torch import bug.
# The two are independent: a venv may be x86_64 and, once recreated, still
# land on 3.13.8. So we re-inspect the interpreter between the checks instead
# of chaining them with elif, guaranteeing both invariants hold on whatever
# venv we end up with. Skip both when the user explicitly chose an interpreter
# via --python.
if [ -z "$_USER_PYTHON" ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
_PY_VER=$("$VENV_DIR/bin/python" -c \
"import sys; print('{}.{}.{}'.format(*sys.version_info[:3]))" 2>/dev/null || echo "")
_inspect_venv() {
"$VENV_DIR/bin/python" -c \
"import platform, sys; print(platform.machine(), '{}.{}.{}'.format(*sys.version_info[:3]))" \
2>/dev/null || echo " "
}
_info=$(_inspect_venv)
_VENV_ARCH=${_info%% *}
_PY_VER=${_info##* }
if [ "$_VENV_ARCH" = "x86_64" ]; then
echo " WARNING: venv was created with an x86_64 (Rosetta) Python on Apple Silicon."
echo " Recreating venv with native arm64 Python ${PYTHON_VERSION}..."
rm -rf "$VENV_DIR"
run_install_cmd "recreate venv (arm64)" uv venv "$VENV_DIR" \
--python "cpython-${PYTHON_VERSION}-macos-aarch64-none"
if [ -x "$VENV_DIR/bin/python" ]; then
: > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true
fi
# Re-inspect: the recreated arm64 venv may still be 3.13.8.
_info=$(_inspect_venv)
_VENV_ARCH=${_info%% *}
_PY_VER=${_info##* }
fi
if [ "$_PY_VER" = "3.13.8" ]; then
echo " WARNING: Python 3.13.8 has a known torch import bug."
echo " Recreating venv with Python 3.12..."
rm -rf "$VENV_DIR"
PYTHON_VERSION="3.12"
run_install_cmd "recreate venv" uv venv "$VENV_DIR" --python "$PYTHON_VERSION"
run_install_cmd "recreate venv" uv venv "$VENV_DIR" \
--python "cpython-${PYTHON_VERSION}-macos-aarch64-none"
if [ -x "$VENV_DIR/bin/python" ]; then
: > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true
fi
@ -2049,7 +2083,7 @@ if [ "$_MIGRATED" = true ]; then
# to prevent transitive torch resolution.
run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.5.8" unsloth-zoo
"unsloth>=2026.5.9" unsloth-zoo
# Resolve pydantic WITH deps so pip pins pydantic-core to the
# matching version (no-torch-runtime.txt below is --no-deps).
# All transitive deps are torch-free.
@ -2062,7 +2096,7 @@ if [ "$_MIGRATED" = true ]; then
else
run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.5.8" unsloth-zoo
"unsloth>=2026.5.9" unsloth-zoo
fi
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
substep "overlaying local repo (editable)..."
@ -2266,7 +2300,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--upgrade-package unsloth --upgrade-package unsloth-zoo \
"unsloth>=2026.5.8" unsloth-zoo
"unsloth>=2026.5.9" unsloth-zoo
# Same pydantic-with-deps trick as the migrated branch.
run_install_cmd "install pydantic (with deps for compatible core)" \
uv pip install --python "$_VENV_PY" pydantic
@ -2284,7 +2318,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
fi
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \
--upgrade-package unsloth "unsloth>=2026.5.8" unsloth-zoo
--upgrade-package unsloth "unsloth>=2026.5.9" unsloth-zoo
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
substep "overlaying unsloth-zoo from git main..."
@ -2316,7 +2350,7 @@ else
tauri_log "STEP" "Installing Unsloth"
substep "installing unsloth (this may take a few minutes)..."
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.8" --torch-backend=auto
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.9" --torch-backend=auto
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
substep "overlaying unsloth-zoo from git main..."

View file

@ -69,7 +69,7 @@ triton = [
]
huggingfacenotorch = [
"unsloth_zoo>=2026.5.4",
"unsloth_zoo>=2026.5.5",
"wheel>=0.42.0",
"packaging",
"numpy",
@ -90,7 +90,7 @@ huggingfacenotorch = [
]
huggingface = [
"unsloth[huggingfacenotorch]",
"unsloth_zoo>=2026.5.4",
"unsloth_zoo>=2026.5.5",
"torchvision",
"unsloth[triton]",
]
@ -580,7 +580,7 @@ colab-ampere-torch220 = [
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
]
colab-new = [
"unsloth_zoo>=2026.5.4",
"unsloth_zoo>=2026.5.5",
"packaging",
"tyro",
"transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0",

View file

@ -176,12 +176,21 @@ def build_mcp_providers(
) -> list:
from data_designer.config.mcp import LocalStdioMCPProvider, MCPProvider # pyright: ignore[reportMissingImports]
# Same gate as the chat MCP path: stdio providers spawn a local subprocess,
# so only build them when this host allows it (desktop / explicit opt-in).
# Skip them otherwise so a recipe carried onto a hosted host cannot spawn.
from core.inference.mcp_client import stdio_mcp_enabled
stdio_allowed = stdio_mcp_enabled()
providers: list[MCPProvider | LocalStdioMCPProvider] = []
for provider in recipe.get("mcp_providers", []):
if not isinstance(provider, dict):
continue
provider_type = provider.get("provider_type")
if provider_type == "stdio":
if not stdio_allowed:
continue
env = provider.get("env")
if not isinstance(env, dict):
env = {}

View file

@ -218,6 +218,8 @@ class AnthropicStreamEmitter:
def __init__(self) -> None:
self.block_index: int = 0
self._text_block_open: bool = False
self._open_tool_call_id: Optional[str] = None
self._open_tool_args_sent: bool = False
self._prev_text: str = ""
self._usage: dict = {}
@ -263,8 +265,10 @@ class AnthropicStreamEmitter:
def finish(self, stop_reason: str = "end_turn") -> list[str]:
"""Close any open block and emit message_delta + message_stop."""
events = []
if self._text_block_open:
if self._text_block_open or self._open_tool_call_id is not None:
events.append(self._close_block())
self._open_tool_call_id = None
self._open_tool_args_sent = False
events.append(
build_anthropic_sse_event(
"message_delta",
@ -310,12 +314,26 @@ class AnthropicStreamEmitter:
return events
def _handle_tool_start(self, event: dict) -> list[str]:
tool_call_id = event.get("tool_call_id", "")
args = event.get("arguments", {})
if tool_call_id and self._open_tool_call_id == tool_call_id:
return self._tool_arguments_delta(args)
events = []
# Close current text block if open
# Close current text block if open.
if self._text_block_open:
events.append(self._close_block())
# Open a tool_use block
# Defensive: if a replacement/different tool_start arrives while a
# tool_use block is open, close the stale block before starting another.
elif self._open_tool_call_id is not None:
events.append(self._close_block())
self._open_tool_call_id = None
self._open_tool_args_sent = False
# Open a tool_use block.
self.block_index += 1
self._open_tool_call_id = tool_call_id
self._open_tool_args_sent = False
events.append(
build_anthropic_sse_event(
"content_block_start",
@ -324,35 +342,43 @@ class AnthropicStreamEmitter:
"index": self.block_index,
"content_block": {
"type": "tool_use",
"id": event.get("tool_call_id", ""),
"id": tool_call_id,
"name": event.get("tool_name", ""),
"input": {},
},
},
)
)
# Emit the arguments as input_json_delta
args = event.get("arguments", {})
if args:
events.append(
build_anthropic_sse_event(
"content_block_delta",
{
"type": "content_block_delta",
"index": self.block_index,
"delta": {
"type": "input_json_delta",
"partial_json": json.dumps(args),
},
},
)
)
events.extend(self._tool_arguments_delta(args))
return events
def _tool_arguments_delta(self, args: dict) -> list[str]:
if not args:
return []
if self._open_tool_args_sent:
return []
self._open_tool_args_sent = True
return [
build_anthropic_sse_event(
"content_block_delta",
{
"type": "content_block_delta",
"index": self.block_index,
"delta": {
"type": "input_json_delta",
"partial_json": json.dumps(args),
},
},
)
]
def _handle_tool_end(self, event: dict) -> list[str]:
events = []
# Close the tool_use block
events.append(self._close_block())
# Close the tool_use block.
if self._open_tool_call_id is not None or self._text_block_open:
events.append(self._close_block())
self._open_tool_call_id = None
self._open_tool_args_sent = False
# Emit custom tool_result event (non-standard, ignored by SDKs)
events.append(
build_anthropic_sse_event(

View file

@ -52,6 +52,7 @@ from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
)
from core.inference.tool_call_parser import (
RENDER_HTML_REPEAT_NUDGE,
parse_tool_calls_from_text as _shared_parse_tool_calls_from_text,
)
@ -2591,6 +2592,105 @@ class LlamaCppBackend:
# ── Lifecycle ─────────────────────────────────────────────────
# GGUF ``general.architecture`` values for diffusion / image models.
# llama.cpp proper has no such architectures, so loading one as a chat
# model dies with "unknown model architecture: '<arch>'". These match
# the patched stable-diffusion.cpp / ComfyUI-GGUF enums (LLM_ARCH_FLUX,
# LLM_ARCH_QWEN_IMAGE, ...). Unsloth publishes FLUX and Qwen-Image GGUFs
# under https://huggingface.co/collections/unsloth/unsloth-diffusion-ggufs.
# Matched exactly (not as a substring) so a chat arch merely containing a
# short token like "wan"/"sd1" (e.g. "taiwan") is not misrouted to Images.
_DIFFUSION_ARCHES = frozenset(
(
"qwen_image",
"flux",
"sd1",
"sdxl",
"sd3",
"aura",
"hidream",
"cosmos",
"ltxv",
"hyvid",
"wan",
"lumina2",
)
)
@staticmethod
def _classify_llama_start_failure(
output: str,
gguf_path: Optional[str],
model_identifier: Optional[str],
) -> str:
"""Explain *why* llama-server failed to start, from its output.
Several distinct failures all otherwise collapse into the same
opaque "invalid GGUF or out of memory" message. The worst case is
a diffusion / image GGUF (FLUX, Qwen-Image, ...) loaded as a chat
model: the file is perfectly valid and there is plenty of memory,
but llama.cpp has no such architecture, so the user is told to free
memory that was never the problem (issue #5842). Pick the most
specific message the captured output supports.
"""
lowered = (output or "").lower()
# Detect Ollama source up front so the arch branch can keep the
# Ollama hint instead of the generic "unsupported arch" message.
gguf = gguf_path or ""
is_ollama = (
".studio_links" in gguf
or os.sep + "ollama_links" + os.sep in gguf
or os.sep + ".cache" + os.sep + "ollama" + os.sep in gguf
or (model_identifier or "").startswith("ollama/")
)
# "unknown model architecture: '<arch>'": diffusion -> Images page,
# Ollama -> Ollama hint, else a precise "unsupported" message. Exact
# match so chat archs are never misrouted.
arch_match = re.search(r"unknown model architecture:\s*'([^']+)'", lowered)
if arch_match:
arch = arch_match.group(1)
if arch in LlamaCppBackend._DIFFUSION_ARCHES:
return (
f"'{arch}' is a diffusion (image-generation) GGUF, which "
"llama-server cannot run as a chat/completion model. Use "
"Studio's Images page to generate with local diffusion "
"GGUFs such as FLUX and Qwen-Image."
)
if is_ollama:
return (
"Some Ollama models do not work with llama.cpp. Try a "
"different model, or use this model directly through "
"Ollama instead."
)
return (
f"llama.cpp does not support this GGUF's model architecture "
f"('{arch}'). The file is valid, but this model type cannot "
"be run with llama-server."
)
# Other Ollama compat failures that do not name an arch. Only when
# the output shows a GGUF compat issue, not OOM / missing binaries.
if is_ollama:
gguf_compat_hints = (
"key not found",
"unknown model architecture",
"failed to load model",
)
if any(h in lowered for h in gguf_compat_hints):
return (
"Some Ollama models do not work with llama.cpp. Try a "
"different model, or use this model directly through "
"Ollama instead."
)
# Fallback: genuinely unknown failure (OOM, missing binary, ...).
return (
"llama-server failed to start. "
"Check that the GGUF file is valid and you have enough memory."
)
def load_model(
self,
*,
@ -3383,31 +3483,12 @@ class LlamaCppBackend:
# Wait for llama-server to become healthy
if not self._wait_for_health(timeout = 600.0):
self._kill_process()
_gguf = gguf_path or ""
_is_ollama = (
".studio_links" in _gguf
or os.sep + "ollama_links" + os.sep in _gguf
or os.sep + ".cache" + os.sep + "ollama" + os.sep in _gguf
or (self._model_identifier or "").startswith("ollama/")
)
# Only show the Ollama-specific message when the server
# output indicates a GGUF compatibility issue, not for
# unrelated failures like OOM or missing binaries.
if _is_ollama:
_output = "\n".join(self._stdout_lines[-50:]).lower()
_gguf_compat_hints = (
"key not found",
"unknown model architecture",
"failed to load model",
)
if any(h in _output for h in _gguf_compat_hints):
raise RuntimeError(
"Some Ollama models do not work with llama.cpp. "
"Try a different model, or use this model directly through Ollama instead."
)
raise RuntimeError(
"llama-server failed to start. "
"Check that the GGUF file is valid and you have enough memory."
self._classify_llama_start_failure(
"\n".join(self._stdout_lines[-50:]),
gguf_path,
self._model_identifier,
)
)
self._healthy = True
@ -4536,6 +4617,7 @@ class LlamaCppBackend:
# a transient failure are allowed (only block when the previous
# identical call succeeded).
_tool_call_history: list[tuple[str, bool]] = [] # (key, failed)
_render_html_succeeded = False
# ── Re-prompt on plan-without-action ─────────────────
# When the model describes what it intends to do (forward-looking
@ -4610,6 +4692,7 @@ class LlamaCppBackend:
_iter_timings = None
_stream_done = False
_last_emitted = ""
provisional_render_html_tool_call_ids = set()
stream_timeout = httpx.Timeout(
connect = 10,
@ -4719,6 +4802,33 @@ class LlamaCppBackend:
tool_calls_acc[idx]["function"][
"arguments"
] += func["arguments"]
current_name = tool_calls_acc[idx][
"function"
].get("name", "")
fallback_id = f"call_{idx}"
current_id = tool_calls_acc[idx].get(
"id", fallback_id
)
already_started = (
current_id
in provisional_render_html_tool_call_ids
)
has_real_id = current_id != fallback_id
if (
current_name == "render_html"
and not _render_html_succeeded
and not already_started
and has_real_id
):
provisional_render_html_tool_call_ids.add(
current_id
)
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": current_id,
"arguments": {},
}
continue
# ── Reasoning tokens ──
@ -4900,13 +5010,25 @@ class LlamaCppBackend:
"content": _stripped,
}
)
available_tool_names = [
tool.get("function", {}).get("name")
for tool in tools
if isinstance(tool, dict)
and isinstance(tool.get("function"), dict)
]
available_tool_names = [
name for name in available_tool_names if name
]
tool_hint = (
" or ".join(available_tool_names) or "an available tool"
)
conversation.append(
{
"role": "user",
"content": (
"STOP. Do NOT write code or explain. "
"You MUST call a tool NOW. "
"Call web_search or python immediately."
f"Call {tool_hint} immediately."
),
}
)
@ -5078,7 +5200,12 @@ class LlamaCppBackend:
arguments = json.loads(raw_args)
except (json.JSONDecodeError, ValueError):
if auto_heal_tool_calls:
arguments = {"query": raw_args}
heal_key = {
"python": "code",
"terminal": "command",
"render_html": "code",
}.get(tool_name, "query")
arguments = {heal_key: raw_args}
else:
arguments = {"raw": raw_args}
else:
@ -5115,14 +5242,18 @@ class LlamaCppBackend:
)
else:
status_text = f"Calling: {tool_name}"
yield {"type": "status", "text": status_text}
_repeat_render_html = (
tool_name == "render_html" and _render_html_succeeded
)
if not _repeat_render_html:
yield {"type": "status", "text": status_text}
yield {
"type": "tool_start",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"arguments": arguments,
}
yield {
"type": "tool_start",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"arguments": arguments,
}
# ── Duplicate call detection ──────────────
# str(dict) is stable here: arguments always comes from
@ -5130,7 +5261,9 @@ class LlamaCppBackend:
# so insertion order is deterministic (Python 3.7+).
_tc_key = tool_name + str(arguments)
_prev = _tool_call_history[-1] if _tool_call_history else None
if _prev and _prev[0] == _tc_key and not _prev[1]:
if _repeat_render_html:
result = RENDER_HTML_REPEAT_NUDGE
elif _prev and _prev[0] == _tc_key and not _prev[1]:
result = (
"You already made this exact call. "
"Do not repeat the same tool call. "
@ -5168,12 +5301,13 @@ class LlamaCppBackend:
session_id = session_id,
)
yield {
"type": "tool_end",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"result": result,
}
if not _repeat_render_html:
yield {
"type": "tool_end",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"result": result,
}
# Nudge model to try a different approach on errors
_error_prefixes = (
@ -5189,6 +5323,8 @@ class LlamaCppBackend:
_is_error = isinstance(result, str) and result.lstrip().startswith(
_error_prefixes
)
if tool_name == "render_html" and not _is_error:
_render_html_succeeded = True
_tool_call_history.append((_tc_key, _is_error))
# Strip image sentinel before feeding result to the LLM
# (the full result with sentinel is still yielded via

View file

@ -5,6 +5,9 @@ from __future__ import annotations
import asyncio
import json
import os
import shlex
import sys
from typing import Any, Optional
from loggers import get_logger
@ -16,7 +19,55 @@ MCP_TOOL_PREFIX = "mcp__"
_oauth_token_store = None
def is_stdio(address: str) -> bool:
"""A non-HTTP address is a local stdio command, e.g.
'npx -y @modelcontextprotocol/server-filesystem /path'."""
return not address.strip().lower().startswith(("http://", "https://"))
def parse_stdio_command(address: str) -> list[str]:
"""Split a stdio command line into argv. Shared by route validation and the
transport so both agree on quoting (notably Windows backslash paths)."""
posix = sys.platform != "win32"
parts = shlex.split(address, posix = posix)
if not posix:
# posix=False keeps backslash paths intact but also keeps the surrounding
# quotes on a token. Strip a matched pair so the argv reaches the
# subprocess clean ('"C:\\Program Files\\node"' -> C:\\Program Files\\node).
parts = [
p[1:-1] if len(p) >= 2 and p[0] == p[-1] and p[0] in "\"'" else p
for p in parts
]
return parts
def stdio_mcp_enabled() -> bool:
"""stdio MCP servers spawn local processes as the backend user (and bypass
the python/terminal sandbox), so they are only allowed when the backend
host is the user's own machine. The Tauri desktop app sets
UNSLOTH_STUDIO_ALLOW_STDIO_MCP=1 (see main.py); advanced localhost /
self-hosted users can opt in with the same variable. It stays off for
Colab and any network (0.0.0.0) bind."""
return os.environ.get("UNSLOTH_STUDIO_ALLOW_STDIO_MCP") == "1"
# Probe timeouts for discovering a server's tool list. OAuth needs minutes for
# first-connect/expired-token browser sign-in; stdio allows for first-run
# package download (e.g. `npx -y ...`); HTTP fails fast.
_HTTP_PROBE_TIMEOUT = 8.0
_OAUTH_PROBE_TIMEOUT = 305.0
_STDIO_PROBE_TIMEOUT = 60.0
def probe_timeout(address: str, use_oauth: bool) -> float:
if use_oauth:
return _OAUTH_PROBE_TIMEOUT
return _STDIO_PROBE_TIMEOUT if is_stdio(address) else _HTTP_PROBE_TIMEOUT
def parse_server_headers(server: dict) -> Optional[dict]:
"""Parsed headers_json. For stdio servers this dict is the process
environment instead of HTTP headers (see _client)."""
raw = server.get("headers_json")
if not raw:
return None
@ -63,6 +114,28 @@ async def clear_oauth_tokens_async(url: str) -> None:
def _client(url: str, headers: Optional[dict], use_oauth: bool = False):
from fastmcp import Client
if is_stdio(url):
# Belt-and-suspenders: never spawn unless stdio is enabled on this host.
if not stdio_mcp_enabled():
raise PermissionError("stdio MCP servers are disabled on this host")
from fastmcp.client.transports import StdioTransport
parts = parse_stdio_command(url)
if not parts:
raise ValueError(f"Empty stdio command: {url!r}")
# env vars ride the headers field (merged over the SDK's safe default env).
# keep_alive=False tears the subprocess down on exit, so a one-shot
# probe/tool call never leaves an orphan process.
return Client(
StdioTransport(
command = parts[0],
args = parts[1:],
env = headers or None,
keep_alive = False,
)
)
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
from fastmcp.mcp_config import infer_transport_type_from_url

View file

@ -18,6 +18,7 @@ cumulative text and dispatches them via ``core.inference.tools``.
"""
import json
import re
import threading
from typing import Callable, Generator, Optional
from urllib.parse import urlparse
@ -27,6 +28,7 @@ from loggers import get_logger
from core.inference.tool_call_parser import (
BUDGET_EXHAUSTED_NUDGE,
DUPLICATE_CALL_NUDGE,
RENDER_HTML_REPEAT_NUDGE,
TOOL_ERROR_NUDGE,
TOOL_ERROR_PREFIXES,
TOOL_XML_SIGNALS,
@ -66,7 +68,34 @@ def _status_for_tool(tool_name: str, arguments: dict) -> str:
return f"Calling: {tool_name}"
_CANONICAL_HEAL_ARG = {"python": "code", "terminal": "command"}
_CANONICAL_HEAL_ARG = {
"python": "code",
"terminal": "command",
"render_html": "code",
}
_FUNCTION_SIGNAL_RE = re.compile(r"<function=([\w-]+)>")
_TOOL_CALL_NAME_RE = re.compile(r'"name"\s*:\s*"([\w-]+)"')
def _detect_render_html_tool_start(content: str) -> bool:
"""Return True when the first drained tool call is clearly render_html."""
function_match = _FUNCTION_SIGNAL_RE.search(content)
tool_call_index = content.find("<tool_call>")
if not function_match and tool_call_index < 0:
return False
if function_match and (
tool_call_index < 0 or function_match.start() < tool_call_index
):
return function_match.group(1) == "render_html"
if tool_call_index >= 0:
name_match = _TOOL_CALL_NAME_RE.search(content[tool_call_index:])
return bool(name_match and name_match.group(1) == "render_html")
return False
def _coerce_arguments(raw_args, *, heal: bool, tool_name: str = "") -> dict:
@ -135,6 +164,7 @@ def run_safetensors_tool_loop(
"""
conversation = list(messages)
tool_call_history: list[tuple[str, bool]] = []
render_html_succeeded = False
final_attempt_done = False
allowed_tool_names = {
(tool.get("function") or {}).get("name")
@ -161,6 +191,8 @@ def run_safetensors_tool_loop(
content_accum = ""
cumulative_display = ""
last_emitted = ""
provisional_render_html_started = False
provisional_render_html_id = f"call_{next_call_id}"
gen = single_turn(conversation)
prev_cumulative = ""
@ -179,6 +211,18 @@ def run_safetensors_tool_loop(
content_accum += delta
if detect_state == _state_draining:
if (
not render_html_succeeded
and not provisional_render_html_started
and _detect_render_html_tool_start(content_accum)
):
provisional_render_html_started = True
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": provisional_render_html_id,
"arguments": {},
}
continue
if detect_state == _state_streaming:
@ -196,6 +240,18 @@ def run_safetensors_tool_loop(
yield {"type": "content", "text": cleaned_before}
cumulative_display = candidate
detect_state = _state_draining
if (
not render_html_succeeded
and not provisional_render_html_started
and _detect_render_html_tool_start(content_accum)
):
provisional_render_html_started = True
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": provisional_render_html_id,
"arguments": {},
}
continue
cumulative_display = candidate
cleaned = strip_tool_markup(cumulative_display)
@ -222,6 +278,18 @@ def run_safetensors_tool_loop(
if is_match:
detect_state = _state_draining
if (
not render_html_succeeded
and not provisional_render_html_started
and _detect_render_html_tool_start(content_accum)
):
provisional_render_html_started = True
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": provisional_render_html_id,
"arguments": {},
}
elif is_prefix and len(stripped) < _MAX_BUFFER_CHARS:
continue
else:
@ -282,6 +350,13 @@ def run_safetensors_tool_loop(
# literal "<tool_call>" prose is preserved.
if content_accum:
yield {"type": "content", "text": content_accum}
if provisional_render_html_started:
yield {
"type": "tool_end",
"tool_name": "render_html",
"tool_call_id": provisional_render_html_id,
"result": "Error: render_html tool call could not be parsed.",
}
yield {"type": "status", "text": ""}
return
content_text = strip_tool_markup(content_accum, final = True)
@ -308,16 +383,20 @@ def run_safetensors_tool_loop(
tool_name = tool_name,
)
yield {"type": "status", "text": _status_for_tool(tool_name, arguments)}
yield {
"type": "tool_start",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"arguments": arguments,
}
repeat_render_html = tool_name == "render_html" and render_html_succeeded
if not repeat_render_html:
yield {"type": "status", "text": _status_for_tool(tool_name, arguments)}
yield {
"type": "tool_start",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"arguments": arguments,
}
tc_key = tool_name + str(arguments)
if allowed_tool_names and tool_name not in allowed_tool_names:
if repeat_render_html:
result = RENDER_HTML_REPEAT_NUDGE
elif allowed_tool_names and tool_name not in allowed_tool_names:
result = (
f"Error: tool '{tool_name}' is not enabled for this "
"request. Use one of the enabled tools or provide a "
@ -345,16 +424,19 @@ def run_safetensors_tool_loop(
logger.exception("Tool %s raised: %s", tool_name, exc)
result = f"Error: tool raised an exception: {exc}"
yield {
"type": "tool_end",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"result": result,
}
if not repeat_render_html:
yield {
"type": "tool_end",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"result": result,
}
is_error = isinstance(result, str) and result.lstrip().startswith(
TOOL_ERROR_PREFIXES
)
if tool_name == "render_html" and not is_error:
render_html_succeeded = True
tool_call_history.append((tc_key, is_error))
# Strip frontend image sentinel from the model's view.

View file

@ -49,6 +49,12 @@ DUPLICATE_CALL_NUDGE = (
"provide your final answer now."
)
RENDER_HTML_REPEAT_NUDGE = (
"Error: render_html was already called for this response. Do not call "
"render_html again in this response unless the user asks for changes. "
"Provide the final answer now."
)
TOOL_ERROR_NUDGE = (
"\n\nThe tool call encountered an issue. Please try a different "
"approach or rephrase your request."
@ -70,6 +76,20 @@ _TC_FUNC_CLOSE_RE = re.compile(r"\s*</function>\s*$")
# `issue-number`, `repo-name`); using `\w+` here dropped those keys.
_TC_PARAM_START_RE = re.compile(r"<parameter=([\w-]+)>\s*")
_TC_PARAM_CLOSE_RE = re.compile(r"\s*</parameter>\s*$")
_PARAM_CLOSE_TAG = "</parameter>"
_FUNC_CLOSE_TAG = "</function>"
def _inside_open_parameter(content: str, pos: int) -> bool:
"""Return True when ``pos`` falls inside an unclosed parameter value."""
last_param_start = -1
for match in _TC_PARAM_START_RE.finditer(content, 0, pos):
last_param_start = match.start()
if last_param_start < 0:
return False
last_param_close = content.rfind(_PARAM_CLOSE_TAG, 0, pos)
last_func_close = content.rfind(_FUNC_CLOSE_TAG, 0, pos)
return last_param_start > max(last_param_close, last_func_close)
def strip_tool_markup(text: str, *, final: bool = False) -> str:
@ -151,7 +171,11 @@ def parse_tool_calls_from_text(content: str, *, id_offset: int = 0) -> list[dict
# optional; don't use </function> as body boundary because code
# values can contain that literal.
if not tool_calls:
func_starts = list(_TC_FUNC_START_RE.finditer(content))
func_starts = [
fm
for fm in _TC_FUNC_START_RE.finditer(content)
if not _inside_open_parameter(content, fm.start())
]
for idx, fm in enumerate(func_starts):
func_name = fm.group(1)
body_start = fm.end()

View file

@ -28,8 +28,11 @@ import urllib.request
from core.inference.mcp_client import (
MCP_TOOL_PREFIX,
call_tool_sync,
is_stdio,
list_tools_async,
parse_server_headers,
probe_timeout,
stdio_mcp_enabled,
)
from storage import mcp_servers_db
@ -511,7 +514,35 @@ TERMINAL_TOOL = {
},
}
ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL]
RENDER_HTML_TOOL = {
"type": "function",
"function": {
"name": "render_html",
"description": (
"Render a self-contained HTML/CSS/JavaScript artifact for the user. "
"Call this at most once per assistant response unless the user "
"explicitly asks for changes in that response. Future user requests "
"for new artifacts may call render_html once. Put the entire document "
"in code, including any CSS in <style> tags and JavaScript in <script> tags."
),
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "A complete self-contained HTML document.",
},
"title": {
"type": "string",
"description": "Short display title for the artifact.",
},
},
"required": ["code"],
},
},
}
ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL, RENDER_HTML_TOOL]
# OpenAI's function.name regex: ^[a-zA-Z0-9_-]{1,64}$ -- enforced before
@ -568,17 +599,19 @@ def _mcp_specs_for_server(server: dict, mcp_tools: list[dict]) -> list[dict]:
async def get_enabled_mcp_tools() -> list[dict]:
servers = [s for s in mcp_servers_db.list_servers() if s.get("is_enabled")]
# Never spawn stdio servers when stdio is disabled on this host (e.g. a DB
# carried over from a desktop install onto a Colab / network deployment).
if not stdio_mcp_enabled():
servers = [s for s in servers if not is_stdio(s["url"])]
if not servers:
return []
# OAuth probes need minutes for first-connect/expired-token browser
# sign-in; non-OAuth probes fail fast. Matches routes/mcp_servers.py.
results = await asyncio.gather(
*(
list_tools_async(
url = s["url"],
headers = parse_server_headers(s),
timeout = 305.0 if s.get("use_oauth") else 8.0,
timeout = probe_timeout(s["url"], bool(s.get("use_oauth"))),
use_oauth = bool(s.get("use_oauth")),
)
for s in servers
@ -603,6 +636,25 @@ async def get_enabled_mcp_tools() -> list[dict]:
_TIMEOUT_UNSET = object()
def _render_html_result(arguments: dict) -> str:
code = arguments.get("code")
if not isinstance(code, str) or not code.strip():
return "Error: render_html requires a non-empty code string."
title = arguments.get("title")
if isinstance(title, str) and title.strip():
safe_title = title.strip()[:120]
return (
f"Rendered HTML artifact: {safe_title}. Do not call render_html "
"again in this response unless the user asks for changes. For a later "
"user request for a new artifact, call render_html once."
)
return (
"Rendered HTML artifact. Do not call render_html again in this response "
"unless the user asks for changes. For a later user request for a new "
"artifact, call render_html once."
)
def execute_tool(
name: str,
arguments: dict,
@ -620,6 +672,8 @@ def execute_tool(
f"execute_tool: name={name}, session_id={session_id}, timeout={timeout}"
)
effective_timeout = _EXEC_TIMEOUT if timeout is _TIMEOUT_UNSET else timeout
if name == "render_html":
return _render_html_result(arguments)
if name.startswith(MCP_TOOL_PREFIX):
try:
_, server_id, tool_name = name.split("__", 2)
@ -630,6 +684,8 @@ def execute_tool(
return f"Error: MCP server '{server_id}' not found"
if not server.get("is_enabled"):
return f"Error: MCP server '{server_id}' is disabled"
if is_stdio(server["url"]) and not stdio_mcp_enabled():
return f"Error: stdio MCP server '{server_id}' is disabled on this host"
return call_tool_sync(
url = server["url"],
headers = parse_server_headers(server),

View file

@ -297,6 +297,11 @@ def _load_desktop_owner() -> dict[str, str] | None:
_DESKTOP_OWNER = _load_desktop_owner()
# The Tauri desktop app runs the backend on the owner's own machine, so local
# stdio MCP servers are safe there. setdefault lets an explicit "0" opt out.
if _DESKTOP_OWNER:
os.environ.setdefault("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "1")
def _desktop_owner() -> dict[str, str] | None:
return _DESKTOP_OWNER
@ -430,6 +435,7 @@ from starlette.requests import Request as _StarletteRequest # noqa: E402
_CSP_SCRIPT_NONCE_HEADER = "x-internal-script-nonce"
_ARTIFACT_PREVIEW_FRAME_PATH = "/api/inference/artifact-preview-frame"
# /content is Colab's working directory — more reliable than env vars which
@ -483,6 +489,7 @@ def _build_csp(script_nonce: "str | None" = None) -> str:
"style-src 'self' 'unsafe-inline'; "
f"{script_src}; "
"font-src 'self' data:; "
"frame-src 'self'; "
f"frame-ancestors {frame_ancestors}; "
"form-action 'self'; "
"base-uri 'self'"
@ -501,13 +508,13 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers.setdefault("Content-Security-Policy", _build_csp(nonce))
# Omit X-Frame-Options in Colab — CSP frame-ancestors handles it, and
# DENY would block serve_kernel_port_as_iframe regardless of CSP.
if not _IS_COLAB:
if not _IS_COLAB and request.url.path != _ARTIFACT_PREVIEW_FRAME_PATH:
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), interest-cohort=()",
"camera=(), microphone=(), geolocation=()",
)
response.headers["server"] = "unsloth-studio"
return response

View file

@ -709,11 +709,12 @@ class ChatCompletionRequest(BaseModel):
enabled_tools: Optional[list[str]] = Field(
None,
description = (
"[x-unsloth] List of enabled tool names. Local GGUF models accept "
"['web_search', 'python', 'terminal']. External providers accept "
"['web_search', 'web_fetch', 'code_execution'] for Anthropic and "
"['web_search', 'code_execution'] for OpenAI Responses. If None, "
"all local tools are enabled and no server-side tools are forwarded."
"[x-unsloth] List of enabled tool names. Local GGUF/safetensors models "
"accept ['web_search', 'python', 'terminal', 'render_html']. External "
"providers accept ['web_search', 'web_fetch', 'code_execution'] for "
"Anthropic and ['web_search', 'code_execution', 'image_generation'] for "
"OpenAI Responses. If None, all local tools are enabled and no "
"server-side tools are forwarded."
),
)
mcp_enabled: Optional[bool] = Field(

View file

@ -134,6 +134,8 @@ class ChatSettingsPayload(BaseModel):
Literal["none", "minimal", "low", "medium", "high", "max", "xhigh"]
] = None
preserveThinking: Optional[bool] = None
collapseHtmlArtifacts: Optional[bool] = None
allowArtifactNetworkAccess: Optional[bool] = None
autoHealToolCalls: Optional[bool] = None
maxToolCallsPerMessage: Optional[int] = Field(default = None, ge = 1)
toolCallTimeout: Optional[int] = Field(default = None, ge = 1)

View file

@ -36,8 +36,18 @@ def list_mcp_tools(payload: McpToolsListRequest) -> McpToolsListResponse:
providers: list[McpToolsProviderResult] = []
tool_to_providers: dict[str, list[str]] = defaultdict(list)
from core.inference.mcp_client import stdio_mcp_enabled
for provider_payload in payload.mcp_providers:
provider_name = str(provider_payload.get("name", "")).strip()
if provider_payload.get("provider_type") == "stdio" and not stdio_mcp_enabled():
providers.append(
McpToolsProviderResult(
name = provider_name,
error = "Local (stdio) MCP servers are disabled on this host.",
)
)
continue
built = build_mcp_providers({"mcp_providers": [provider_payload]})
if len(built) != 1:
providers.append(

View file

@ -239,6 +239,135 @@ router = APIRouter()
studio_router = APIRouter()
_ARTIFACT_PREVIEW_FRAME_ANCESTORS = "'self' tauri://localhost http://tauri.localhost"
_ARTIFACT_PREVIEW_FRAME_STRICT_CSP = (
"default-src 'none'; "
"script-src 'unsafe-inline'; "
"style-src 'unsafe-inline'; "
"img-src data: blob:; "
"font-src data:; "
"media-src data: blob:; "
"connect-src 'none'; "
"object-src 'none'; "
"base-uri 'none'; "
"form-action 'none'; "
f"frame-ancestors {_ARTIFACT_PREVIEW_FRAME_ANCESTORS}; "
"sandbox allow-scripts"
)
_ARTIFACT_PREVIEW_FRAME_NETWORK_CSP = (
"default-src http: https: data: blob:; "
"script-src 'unsafe-inline' 'unsafe-eval' http: https: data: blob:; "
"script-src-elem 'unsafe-inline' http: https: data: blob:; "
"style-src 'unsafe-inline' http: https: data: blob:; "
"style-src-elem 'unsafe-inline' http: https: data: blob:; "
"img-src http: https: data: blob:; "
"font-src http: https: data: blob:; "
"media-src http: https: data: blob:; "
"connect-src http: https: ws: wss: data: blob:; "
"worker-src http: https: blob:; "
"object-src 'none'; "
"base-uri 'none'; "
"form-action 'none'; "
f"frame-ancestors {_ARTIFACT_PREVIEW_FRAME_ANCESTORS}; "
"sandbox allow-scripts"
)
_ARTIFACT_PREVIEW_FRAME_HTML = """<!doctype html>
<html>
<head><meta charset=\"utf-8\" /></head>
<body>
<script>
(() => {
const createMemoryStorage = () => {
const data = new Map();
return {
get length() { return data.size; },
key: (index) => Array.from(data.keys())[index] ?? null,
getItem: (key) => data.has(String(key)) ? data.get(String(key)) : null,
setItem: (key, value) => data.set(String(key), String(value)),
removeItem: (key) => data.delete(String(key)),
clear: () => data.clear(),
};
};
const installStorageFallback = (name) => {
try {
void window[name];
return;
} catch {
// Opaque-origin sandboxed frames throw SecurityError for Web Storage.
}
try {
Object.defineProperty(window, name, {
value: createMemoryStorage(),
configurable: true,
});
} catch {
// Leave the sandbox failure contained in the artifact if the
// browser refuses to shadow the Web Storage accessor.
}
};
const installStorageFallbacks = () => {
installStorageFallback("localStorage");
installStorageFallback("sessionStorage");
};
const render = (html) => {
installStorageFallbacks();
document.open();
document.write(html);
document.close();
};
installStorageFallbacks();
window.addEventListener("message", (event) => {
const data = event.data;
if (!data || data.type !== "unsloth:artifact-html" || typeof data.html !== "string") return;
render(data.html);
});
})();
</script>
</body>
</html>"""
@studio_router.get("/artifact-preview-frame", include_in_schema = False)
async def artifact_preview_frame(
request: Request,
allow_network: bool = False,
token: Optional[str] = None,
):
"""Serve the opaque sandbox shell used for client-side HTML artifacts."""
if allow_network:
auth_header = request.headers.get("authorization")
if auth_header and auth_header.lower().startswith("bearer "):
jwt_token = auth_header[7:]
elif token:
jwt_token = token
else:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Missing authentication token",
)
from fastapi.security import HTTPAuthorizationCredentials
creds = HTTPAuthorizationCredentials(scheme = "Bearer", credentials = jwt_token)
await get_current_subject(creds)
csp = (
_ARTIFACT_PREVIEW_FRAME_NETWORK_CSP
if allow_network
else _ARTIFACT_PREVIEW_FRAME_STRICT_CSP
)
return Response(
content = _ARTIFACT_PREVIEW_FRAME_HTML,
media_type = "text/html; charset=utf-8",
headers = {
"Cache-Control": "no-store",
"Content-Security-Policy": csp,
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
},
)
def _detect_safetensors_features(backend, chat_template: Optional[str]) -> dict:
"""Classify reasoning/tool capabilities via the GGUF classifier so
flags match across backends. gpt-oss is overridden because Harmony
@ -419,13 +548,28 @@ async def _await_cancel_then_close(cancel_event, resp) -> None:
return
# Appended to tool-use nudge to discourage plan-without-action
# Appended to tool-use nudge to discourage plan-without-action.
# Keep render_html guidance gated to turns where the artifact tool is actually
# present in the tool schema; otherwise small local models can hallucinate a
# missing tool call instead of following the fenced-HTML fallback prompt.
_TOOL_ACTION_NUDGE = (
" IMPORTANT: Always call tools directly -- never write code yourself."
" Never describe what you plan to do -- just call the tool immediately."
" For any code request, call the python tool. For any factual question, call web_search."
" Do NOT output code blocks -- use the python tool instead."
" For non-artifact code requests, call the python tool when it is available."
" For factual questions that require current information, call web_search when it is available."
" Do NOT output raw code blocks when an enabled tool can satisfy the request."
)
_ARTIFACT_TOOL_ACTION_NUDGE = (
" For HTML, CSS, or JavaScript artifact requests, call render_html once when "
"it is available. After render_html succeeds, do not call it again in the "
"same response unless the user asks for changes. Future user requests for "
"new artifacts may call render_html once."
)
def _tool_action_nudge(has_artifact: bool) -> str:
return _TOOL_ACTION_NUDGE + (_ARTIFACT_TOOL_ACTION_NUDGE if has_artifact else "")
# Strip tool-call XML the speculative buffer in core/inference/llama_cpp.py
# split across the visible/DRAIN boundary. Four leak shapes:
@ -2698,6 +2842,7 @@ async def openai_chat_completions(
_tool_names = {t["function"]["name"] for t in tools_to_use}
_has_web = "web_search" in _tool_names
_has_code = "python" in _tool_names or "terminal" in _tool_names
_has_artifact = "render_html" in _tool_names
_date_line = f"The current date is {_date.today().isoformat()}."
@ -2719,34 +2864,34 @@ async def openai_chat_completions(
"Use code execution for math, calculations, data processing, "
"or to parse and analyze information from tool results."
)
_artifact_tips = (
"Use render_html for HTML, CSS, or JavaScript artifact requests "
"with one complete self-contained HTML document in the code argument. "
"Call it once, then do not call it again in the same response unless "
"the user asks for changes. Future user requests for new artifacts may "
"call render_html once."
)
if _has_web and _has_code:
_tool_tip_parts = []
if _has_web:
_tool_tip_parts.append(_web_tips)
if _has_code:
_tool_tip_parts.append(_code_tips)
if _has_artifact:
_tool_tip_parts.append(_artifact_tips)
if _tool_tip_parts:
_nudge = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"tools rather than answering from memory. "
+ _web_tips
+ " "
+ _code_tips
)
elif _has_code:
_nudge = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"code execution rather than answering from memory. " + _code_tips
)
elif _has_web:
_nudge = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"web search for up-to-date or uncertain factual "
"information rather than answering from memory. " + _web_tips
+ " ".join(_tool_tip_parts)
)
else:
_nudge = ""
if _nudge:
_nudge += _TOOL_ACTION_NUDGE
_nudge += _tool_action_nudge(_has_artifact)
# Append nudge to system prompt (preserve user's prompt)
if system_prompt:
system_prompt = system_prompt.rstrip() + "\n\n" + _nudge
@ -3221,6 +3366,7 @@ async def openai_chat_completions(
_sf_tool_names = {t["function"]["name"] for t in _sf_tools_to_use}
_sf_has_web = "web_search" in _sf_tool_names
_sf_has_code = "python" in _sf_tool_names or "terminal" in _sf_tool_names
_sf_has_artifact = "render_html" in _sf_tool_names
_sf_date_line = f"The current date is {_date.today().isoformat()}."
_sf_model_size_b = _extract_model_size_b(model_name)
@ -3239,35 +3385,35 @@ async def openai_chat_completions(
"Use code execution for math, calculations, data processing, "
"or to parse and analyze information from tool results."
)
_sf_artifact_tips = (
"Use render_html for HTML, CSS, or JavaScript artifact requests "
"with one complete self-contained HTML document in the code argument. "
"Call it once, then do not call it again in the same response unless "
"the user asks for changes. Future user requests for new artifacts may "
"call render_html once."
)
if _sf_has_web and _sf_has_code:
_sf_tool_tip_parts = []
if _sf_has_web:
_sf_tool_tip_parts.append(_sf_web_tips)
if _sf_has_code:
_sf_tool_tip_parts.append(_sf_code_tips)
if _sf_has_artifact:
_sf_tool_tip_parts.append(_sf_artifact_tips)
if _sf_tool_tip_parts:
_sf_nudge = (
_sf_date_line + " "
"You have access to tools. When appropriate, prefer using "
"tools rather than answering from memory. "
+ _sf_web_tips
+ " "
+ _sf_code_tips
)
elif _sf_has_code:
_sf_nudge = (
_sf_date_line + " "
"You have access to tools. When appropriate, prefer using "
"code execution rather than answering from memory. " + _sf_code_tips
)
elif _sf_has_web:
_sf_nudge = (
_sf_date_line + " "
"You have access to tools. When appropriate, prefer using "
"web search for up-to-date or uncertain factual "
"information rather than answering from memory. " + _sf_web_tips
+ " ".join(_sf_tool_tip_parts)
)
else:
_sf_nudge = ""
_sf_system_prompt = system_prompt
if _sf_nudge:
_sf_nudge += _TOOL_ACTION_NUDGE
_sf_nudge += _tool_action_nudge(_sf_has_artifact)
if _sf_system_prompt:
_sf_system_prompt = _sf_system_prompt.rstrip() + "\n\n" + _sf_nudge
else:
@ -4914,6 +5060,7 @@ async def anthropic_messages(
_tool_names = {t["function"]["name"] for t in openai_tools}
_has_web = "web_search" in _tool_names
_has_code = "python" in _tool_names or "terminal" in _tool_names
_has_artifact = "render_html" in _tool_names
_date_line = f"The current date is {_date.today().isoformat()}."
_model_size_b = _extract_model_size_b(model_name)
@ -4932,34 +5079,33 @@ async def anthropic_messages(
"Use code execution for math, calculations, data processing, "
"or to parse and analyze information from tool results."
)
_artifact_tips = (
"Use render_html for HTML, CSS, or JavaScript artifact requests "
"with one complete self-contained HTML document in the code argument. "
"Call it once, then do not call it again in the same response unless "
"the user asks for changes. Future user requests for new artifacts may "
"call render_html once."
)
if _has_web and _has_code:
_tool_tip_parts = []
if _has_web:
_tool_tip_parts.append(_web_tips)
if _has_code:
_tool_tip_parts.append(_code_tips)
if _has_artifact:
_tool_tip_parts.append(_artifact_tips)
if _tool_tip_parts:
_nudge = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"tools rather than answering from memory. "
+ _web_tips
+ " "
+ _code_tips
)
elif _has_code:
_nudge = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"code execution rather than answering from memory. " + _code_tips
)
elif _has_web:
_nudge = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"web search for up-to-date or uncertain factual "
"information rather than answering from memory. " + _web_tips
"tools rather than answering from memory. " + " ".join(_tool_tip_parts)
)
else:
_nudge = ""
if _nudge:
_nudge += _TOOL_ACTION_NUDGE
_nudge += _tool_action_nudge(_has_artifact)
# Inject into system prompt
if openai_messages and openai_messages[0].get("role") == "system":
openai_messages[0]["content"] = (
@ -5147,6 +5293,7 @@ async def _anthropic_tool_non_streaming(run_gen, message_id, model_name):
baseline, not against turn N's final length.
"""
content_blocks: list = []
tool_blocks_by_id: dict[str, AnthropicResponseToolUseBlock] = {}
usage = {}
prev_text = ""
@ -5165,13 +5312,25 @@ async def _anthropic_tool_non_streaming(run_gen, message_id, model_name):
else:
content_blocks.append(AnthropicResponseTextBlock(text = new))
elif etype == "tool_start":
content_blocks.append(
AnthropicResponseToolUseBlock(
id = event["tool_call_id"],
name = event["tool_name"],
input = event.get("arguments", {}),
)
tool_call_id = event["tool_call_id"]
arguments = event.get("arguments", {})
existing_tool_block = (
tool_blocks_by_id.get(tool_call_id) if tool_call_id else None
)
if existing_tool_block is not None:
if arguments or not existing_tool_block.input:
existing_tool_block.input = arguments
if event.get("tool_name") and not existing_tool_block.name:
existing_tool_block.name = event["tool_name"]
else:
tool_block = AnthropicResponseToolUseBlock(
id = tool_call_id,
name = event["tool_name"],
input = arguments,
)
if tool_call_id:
tool_blocks_by_id[tool_call_id] = tool_block
content_blocks.append(tool_block)
elif etype == "tool_end":
prev_text = ""
elif etype == "metadata":

View file

@ -11,8 +11,12 @@ from fastapi import APIRouter, Depends, HTTPException
from auth.authentication import get_current_subject
from core.inference.mcp_client import (
clear_oauth_tokens_async,
is_stdio,
list_tools_async,
parse_server_headers,
parse_stdio_command,
probe_timeout,
stdio_mcp_enabled,
)
from models.mcp_servers import (
McpServerCreate,
@ -28,16 +32,30 @@ logger = structlog.get_logger(__name__)
router = APIRouter()
_PROBE_TIMEOUT_SECONDS = 8.0
# When OAuth probes need to open a browser, wait long enough for the user to
# sign in. Matches fastmcp's default OAuth callback_timeout (300 s) + slack.
_OAUTH_PROBE_TIMEOUT_SECONDS = 305.0
def _validate_url(url: str) -> str:
trimmed = (url or "").strip()
if not trimmed:
raise HTTPException(status_code = 400, detail = "url must not be empty")
# When stdio is enabled on this host, a non-HTTP value is a local command.
# Reuse this field so stdio servers ride the existing CRUD/storage with no
# schema change. When stdio is disabled the value falls through to the
# http-only validation below, so non-HTTP input is just a bad URL (400).
if stdio_mcp_enabled() and is_stdio(trimmed):
try:
parts = parse_stdio_command(trimmed)
except ValueError as exc:
raise HTTPException(status_code = 400, detail = f"Invalid command: {exc}")
if not parts or not parts[0].strip():
raise HTTPException(status_code = 400, detail = "command must not be empty")
if "://" in parts[0]:
# A URL-scheme first token is a mistyped URL, not a command. Reject
# it cleanly instead of exec-ing it (mirrors the frontend check).
raise HTTPException(
status_code = 400,
detail = "Enter an http(s):// URL, or a local command whose "
"first token is an executable (not a URL).",
)
return trimmed
parsed = urlparse(trimmed)
if parsed.scheme not in ("http", "https"):
raise HTTPException(
@ -91,6 +109,9 @@ async def create_mcp_server(
raise HTTPException(status_code = 400, detail = "display_name must not be empty")
url = _validate_url(payload.url)
headers = _normalize_headers(payload.headers)
# OAuth is HTTP-only; force it off for stdio commands so a stale flag can't
# push the probe onto the 305s OAuth timeout. Backend is the enforcer.
use_oauth = payload.use_oauth and not is_stdio(url)
server_id = uuid.uuid4().hex[:16]
mcp_servers_db.create_server(
@ -99,7 +120,7 @@ async def create_mcp_server(
url = url,
headers_json = json.dumps(headers) if headers else None,
is_enabled = payload.is_enabled,
use_oauth = payload.use_oauth,
use_oauth = use_oauth,
)
return _row_to_response(mcp_servers_db.get_server(server_id))
@ -132,6 +153,9 @@ def _changes_from_payload(payload: McpServerUpdate) -> dict:
status_code = 400, detail = "use_oauth must be true or false"
)
changes["use_oauth"] = payload.use_oauth
# stdio is OAuth-less: drop a stale OAuth flag when switching to a command.
if "url" in changes and is_stdio(changes["url"]):
changes["use_oauth"] = False
return changes
@ -147,6 +171,15 @@ async def update_mcp_server(
changes = _changes_from_payload(payload)
if not changes:
raise HTTPException(status_code = 400, detail = "No fields to update")
# headers == HTTP headers (remote) or env vars (stdio). On a transport-type
# switch with no new headers, drop the old ones so env secrets are not
# re-sent as HTTP headers (or vice versa).
if (
"url" in changes
and is_stdio(changes["url"]) != is_stdio(old["url"])
and "headers_json" not in changes
):
changes["headers_json"] = None
# Clear persisted OAuth tokens when the URL changes or OAuth is
# disabled; fastmcp keys tokens by URL and would otherwise let a
# re-pointed server silently inherit the old account's credentials.
@ -180,15 +213,19 @@ async def refresh_mcp_server_tools(
server = mcp_servers_db.get_server(server_id)
if not server:
raise HTTPException(status_code = 404, detail = "MCP server not found")
# Refresh uses the stored address, so re-check the stdio gate here too: a
# stdio row from a desktop DB must not spawn on a hosted/network host.
if is_stdio(server["url"]) and not stdio_mcp_enabled():
raise HTTPException(
status_code = 400, detail = "stdio MCP servers are disabled on this host"
)
use_oauth = bool(server.get("use_oauth"))
try:
tools = await list_tools_async(
url = server["url"],
headers = parse_server_headers(server),
timeout = _OAUTH_PROBE_TIMEOUT_SECONDS
if use_oauth
else _PROBE_TIMEOUT_SECONDS,
timeout = probe_timeout(server["url"], use_oauth),
use_oauth = use_oauth,
)
except Exception as exc: # noqa: BLE001 — surface transport+timeout errors to UI
@ -212,9 +249,7 @@ async def test_mcp_server(
tools = await list_tools_async(
url = url,
headers = headers,
timeout = _OAUTH_PROBE_TIMEOUT_SECONDS
if payload.use_oauth
else _PROBE_TIMEOUT_SECONDS,
timeout = probe_timeout(url, payload.use_oauth),
use_oauth = payload.use_oauth,
)
except Exception as exc: # noqa: BLE001

View file

@ -38,6 +38,7 @@ from routes.inference import (
_normalize_anthropic_openai_images,
_select_anthropic_server_tools,
_anthropic_requested_studio_tools,
_anthropic_tool_non_streaming,
anthropic_messages,
)
from state.tool_policy import reset_tool_policy, set_tool_policy
@ -551,6 +552,54 @@ class TestAnthropicStreamEmitter:
assert "tool_use" in events[1]
assert "input_json_delta" in events[2]
def test_duplicate_tool_start_merges_into_open_tool_block(self):
e = AnthropicStreamEmitter()
e.start("msg_1", "m")
first_events = e.feed(
{
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": "call_0",
"arguments": {},
}
)
second_events = e.feed(
{
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": "call_0",
"arguments": {"code": "<!doctype html><html></html>"},
}
)
first_payloads = [
json.loads(event.split("data: ")[1]) for event in first_events
]
second_payloads = [
json.loads(event.split("data: ")[1]) for event in second_events
]
tool_starts = [
payload
for payload in first_payloads + second_payloads
if payload["type"] == "content_block_start"
and payload["content_block"]["type"] == "tool_use"
]
assert len(tool_starts) == 1
assert tool_starts[0]["content_block"]["id"] == "call_0"
assert second_payloads == [
{
"type": "content_block_delta",
"index": tool_starts[0]["index"],
"delta": {
"type": "input_json_delta",
"partial_json": json.dumps(
{"code": "<!doctype html><html></html>"}
),
},
}
]
def test_tool_end_closes_tool_opens_new_text_block(self):
e = AnthropicStreamEmitter()
e.start("msg_1", "m")
@ -674,6 +723,49 @@ class TestAnthropicStreamEmitter:
assert parsed["delta"]["text"] == "After tool"
# =====================================================================
# Non-streaming tool response tests
# =====================================================================
class TestAnthropicToolNonStreaming:
def test_duplicate_tool_start_replaces_provisional_tool_block(self):
def _run_gen():
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": "call_0",
"arguments": {},
}
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": "call_0",
"arguments": {"code": "<!doctype html><html></html>"},
}
yield {
"type": "tool_end",
"tool_name": "render_html",
"tool_call_id": "call_0",
"result": "Rendered HTML artifact.",
}
response = asyncio.run(_anthropic_tool_non_streaming(_run_gen, "msg_1", "m"))
body = json.loads(response.body)
tool_blocks = [
block for block in body["content"] if block["type"] == "tool_use"
]
assert tool_blocks == [
{
"type": "tool_use",
"id": "call_0",
"name": "render_html",
"input": {"code": "<!doctype html><html></html>"},
}
]
# =====================================================================
# Pass-through emitter tests (client-side tool execution path)
# =====================================================================

View file

@ -0,0 +1,144 @@
# 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 LlamaCppBackend._classify_llama_start_failure.
When llama-server exits before becoming healthy, load_model turns its
captured stdout/stderr into a user-facing reason. A diffusion / image
GGUF (FLUX, Qwen-Image, ...) is a valid file with plenty of memory, so
the generic "invalid file or out of memory" message is actively
misleading (issue #5842). These tests pin the classification.
"""
from __future__ import annotations
import sys
import types as _types
from pathlib import Path
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# Match the stubbing pattern in sibling tests so the module imports in a
# lightweight env without fastapi.
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
# Give the structlog stub a real get_logger: a bare ModuleType poisons
# sys.modules for later tests that call structlog.get_logger at import time.
_structlog_stub = _types.ModuleType("structlog")
_structlog_stub.get_logger = lambda *a, **k: __import__("logging").getLogger(
"structlog"
)
sys.modules.setdefault("structlog", _structlog_stub)
if not hasattr(sys.modules["structlog"], "get_logger"):
sys.modules["structlog"].get_logger = _structlog_stub.get_logger
from core.inference.llama_cpp import LlamaCppBackend # noqa: E402
_classify = LlamaCppBackend._classify_llama_start_failure
# Real llama-server failure lines (lower-cased downstream anyway).
_QWEN_IMAGE_OUT = (
"load_model: loading model 'qwen-image-edit-2511-Q4_K_M.gguf'\n"
"llama_model_load: error loading model: unknown model architecture: 'qwen_image'\n"
"llama_model_load_from_file_impl: failed to load model"
)
_OOM_OUT = (
"ggml_backend_cuda_buffer_type_alloc_buffer: allocating 12000.00 MiB on "
"device 0: cudaMalloc failed: out of memory"
)
class TestDiffusionArchitectures:
def test_qwen_image_routes_to_images_page(self):
msg = _classify(_QWEN_IMAGE_OUT, "/models/qwen-image.gguf", "local/qwen-image")
assert "diffusion" in msg.lower()
assert "Images page" in msg
assert "qwen_image" in msg
# Must NOT keep blaming memory / file validity.
assert "out of memory" not in msg.lower()
assert "enough memory" not in msg.lower()
# Parametrize over the production set so new arches are auto-covered.
@pytest.mark.parametrize("arch", sorted(LlamaCppBackend._DIFFUSION_ARCHES))
def test_every_diffusion_arch_is_recognised(self, arch):
out = f"error loading model: unknown model architecture: '{arch}'"
msg = _classify(out, f"/models/{arch}.gguf", f"local/{arch}")
assert "diffusion" in msg.lower()
assert "Images page" in msg
assert arch in msg
class TestUnsupportedNonDiffusionArchitecture:
def test_unknown_llm_arch_says_unsupported_not_oom(self):
out = "error loading model: unknown model architecture: 'some_new_llm'"
msg = _classify(out, "/models/x.gguf", "local/x")
assert "some_new_llm" in msg
assert "architecture" in msg.lower()
# Specific, not the misleading memory message.
assert "enough memory" not in msg.lower()
assert "diffusion" not in msg.lower()
# Exact match: a chat arch merely containing a diffusion token (wan,
# sd1, flux, ...) must not be routed to the Images page.
@pytest.mark.parametrize(
"arch",
[
"taiwan", # contains "wan"
"swan_llm", # contains "wan"
"fluxion", # contains "flux"
"sd1234", # contains "sd1"
"sd3_chat", # contains "sd3"
"aura2_text", # contains "aura"
"cosmos_reason", # contains "cosmos"
"qwen_image_text", # contains "qwen_image"
],
)
def test_arch_containing_diffusion_token_is_not_misrouted(self, arch):
out = f"error loading model: unknown model architecture: '{arch}'"
msg = _classify(out, f"/models/{arch}.gguf", f"local/{arch}")
assert arch in msg
assert "does not support" in msg.lower()
assert "diffusion" not in msg.lower()
assert "Images page" not in msg
class TestOllamaAndFallback:
_OLLAMA_GGUF = (
f"/home/u/.ollama{__import__('os').sep}ollama_links"
f"{__import__('os').sep}m.gguf"
)
def test_ollama_compat_message_still_works(self):
out = "llama_model_load: error loading model: key not found"
msg = _classify(out, self._OLLAMA_GGUF, "ollama/llama3")
assert "Ollama" in msg
def test_ollama_unknown_arch_keeps_ollama_guidance(self):
# Ollama + non-diffusion unknown arch keeps the Ollama hint, not the
# generic llama.cpp "unsupported" message.
out = "error loading model: unknown model architecture: 'some_new_llm'"
msg = _classify(out, self._OLLAMA_GGUF, "ollama/some-new")
assert "Ollama" in msg
assert "directly through Ollama" in msg
assert "does not support" not in msg.lower()
def test_ollama_diffusion_arch_still_routes_to_images(self):
# Diffusion routing wins over the Ollama hint.
out = "error loading model: unknown model architecture: 'flux'"
msg = _classify(out, self._OLLAMA_GGUF, "ollama/flux")
assert "diffusion" in msg.lower()
assert "Images page" in msg
def test_generic_oom_keeps_memory_message(self):
msg = _classify(_OOM_OUT, "/models/big.gguf", "local/big")
assert "enough memory" in msg.lower()
assert "diffusion" not in msg.lower()
def test_empty_output_is_safe(self):
msg = _classify("", None, None)
assert "llama-server failed to start" in msg

View file

@ -0,0 +1,236 @@
"""Tests for the proposed PR #5863 improvements.
Covers: _client() self-gating + keep_alive, OAuth normalised off for stdio
(create + update), env/header dropped on a transport-type switch, and the
backend rejecting a command whose first token is a URL scheme.
Run from studio/backend: python -m pytest tests/test_mcp_stdio_improvements.py -q
"""
import asyncio
import pytest
from fastapi import HTTPException
from core.inference import mcp_client
from storage import mcp_servers_db
def _reset_db(tmp_path, monkeypatch):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(mcp_servers_db, "_schema_ready", False)
def _enable(monkeypatch):
monkeypatch.setenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "1")
def _disable(monkeypatch):
monkeypatch.delenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", raising = False)
# ── P1: _client() self-gates the stdio sink ─────────────────────────
def test_client_refuses_stdio_when_disabled(monkeypatch):
_disable(monkeypatch)
with pytest.raises(PermissionError):
mcp_client._client("npx -y server /tmp", None)
def test_client_builds_stdio_when_enabled_without_spawning(monkeypatch):
_enable(monkeypatch)
# Constructing the Client must not spawn the subprocess (spawn happens on
# __aenter__); we only assert it builds.
client = mcp_client._client("npx -y server /tmp", {"K": "v"})
assert client is not None
def test_client_http_unaffected_by_gate(monkeypatch):
_disable(monkeypatch)
assert mcp_client._client("https://example.com/mcp", None) is not None
# ── P3: OAuth normalised off for stdio (create + update) ────────────
def test_create_forces_oauth_off_for_stdio(tmp_path, monkeypatch):
import routes.mcp_servers as routes_mcp
from models.mcp_servers import McpServerCreate
_reset_db(tmp_path, monkeypatch)
_enable(monkeypatch)
resp = asyncio.run(
routes_mcp.create_mcp_server(
McpServerCreate(
display_name = "FS", url = "npx -y server /tmp", use_oauth = True
),
current_subject = "u",
)
)
assert resp.use_oauth is False
assert mcp_servers_db.get_server(resp.id)["use_oauth"] == 0
def test_create_keeps_oauth_for_http(tmp_path, monkeypatch):
import routes.mcp_servers as routes_mcp
from models.mcp_servers import McpServerCreate
_reset_db(tmp_path, monkeypatch)
_enable(monkeypatch)
resp = asyncio.run(
routes_mcp.create_mcp_server(
McpServerCreate(display_name = "GH", url = "https://gh/mcp", use_oauth = True),
current_subject = "u",
)
)
assert resp.use_oauth is True
def test_update_url_to_stdio_clears_oauth(tmp_path, monkeypatch):
import routes.mcp_servers as routes_mcp
from models.mcp_servers import McpServerUpdate
_reset_db(tmp_path, monkeypatch)
_enable(monkeypatch)
monkeypatch.setattr(mcp_client, "_oauth_token_store", None)
monkeypatch.setattr(
routes_mcp, "clear_oauth_tokens_async", lambda *a, **k: asyncio.sleep(0)
)
mcp_servers_db.create_server(
id = "s1", display_name = "A", url = "https://a/mcp", use_oauth = True
)
resp = asyncio.run(
routes_mcp.update_mcp_server(
"s1", McpServerUpdate(url = "npx -y server /tmp"), current_subject = "u"
)
)
assert resp.use_oauth is False
# ── P4: env/headers dropped on a transport-type switch ──────────────
def test_switch_stdio_to_http_drops_env(tmp_path, monkeypatch):
import routes.mcp_servers as routes_mcp
from models.mcp_servers import McpServerUpdate
_reset_db(tmp_path, monkeypatch)
_enable(monkeypatch)
mcp_servers_db.create_server(
id = "s1",
display_name = "A",
url = "npx server",
headers_json = '{"API_KEY": "secret"}',
)
resp = asyncio.run(
routes_mcp.update_mcp_server(
"s1", McpServerUpdate(url = "https://remote/mcp"), current_subject = "u"
)
)
# the stdio env must NOT survive as HTTP headers on the remote endpoint
assert resp.headers == {}
assert mcp_servers_db.get_server("s1")["headers_json"] is None
def test_switch_keeps_explicitly_supplied_headers(tmp_path, monkeypatch):
import routes.mcp_servers as routes_mcp
from models.mcp_servers import McpServerUpdate
_reset_db(tmp_path, monkeypatch)
_enable(monkeypatch)
mcp_servers_db.create_server(
id = "s1",
display_name = "A",
url = "npx server",
headers_json = '{"API_KEY": "secret"}',
)
resp = asyncio.run(
routes_mcp.update_mcp_server(
"s1",
McpServerUpdate(
url = "https://remote/mcp", headers = {"Authorization": "Bearer new"}
),
current_subject = "u",
)
)
assert resp.headers == {"Authorization": "Bearer new"}
def test_same_transport_edit_keeps_headers(tmp_path, monkeypatch):
import routes.mcp_servers as routes_mcp
from models.mcp_servers import McpServerUpdate
_reset_db(tmp_path, monkeypatch)
_enable(monkeypatch)
mcp_servers_db.create_server(
id = "s1",
display_name = "A",
url = "npx server",
headers_json = '{"API_KEY": "secret"}',
)
# editing only the display name (still stdio) must not wipe env vars
resp = asyncio.run(
routes_mcp.update_mcp_server(
"s1", McpServerUpdate(display_name = "B"), current_subject = "u"
)
)
assert resp.headers == {"API_KEY": "secret"}
# ── P5: reject a command whose first token is a URL scheme ───────────
def test_validate_url_rejects_url_scheme_command_when_enabled(monkeypatch):
from routes.mcp_servers import _validate_url
_enable(monkeypatch)
for bad in ["ftp://host/x", "file:///etc/passwd", "ws://h/y"]:
with pytest.raises(HTTPException) as exc:
_validate_url(bad)
assert exc.value.status_code == 400
def test_validate_url_allows_url_in_argument(monkeypatch):
from routes.mcp_servers import _validate_url
_enable(monkeypatch)
# :// inside an ARGUMENT (not the first token) is still a valid command
assert _validate_url("npx server --url https://x/mcp") == (
"npx server --url https://x/mcp"
)
# ── P6: Data Recipe stdio path obeys the same host gate ─────────────
# build_mcp_providers needs the data_designer plugin, which is only installed in
# the Studio test job; skip there rather than fail the core matrix.
_STDIO_RECIPE = {
"mcp_providers": [
{
"provider_type": "stdio",
"name": "fs",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"env": {},
}
]
}
def test_data_recipe_skips_stdio_when_disabled(monkeypatch):
pytest.importorskip("data_designer")
_disable(monkeypatch)
from core.data_recipe.service import build_mcp_providers
# gate off -> the stdio provider is dropped (no subprocess can be spawned)
assert build_mcp_providers(_STDIO_RECIPE) == []
def test_data_recipe_builds_stdio_when_enabled(monkeypatch):
pytest.importorskip("data_designer")
_enable(monkeypatch)
from core.data_recipe.service import build_mcp_providers
built = build_mcp_providers(_STDIO_RECIPE)
assert len(built) == 1 # constructed (not spawned) only when enabled

View file

@ -0,0 +1,367 @@
"""Verification tests for PR #5863 (stdio MCP server support).
Covers the pure helpers (is_stdio / parse_stdio_command / stdio_mcp_enabled /
probe_timeout), the route-level _validate_url gate, and - most importantly -
that the UNSLOTH_STUDIO_ALLOW_STDIO_MCP gate blocks the stdio transport at all
five enforcement points (create, update, test, refresh, discovery, execute)
when disabled, and reaches it when enabled. The transport (_client) is stubbed
so no real subprocess is spawned; a recorder asserts whether it was reached.
Run from studio/backend: python -m pytest tests/test_mcp_stdio_pr5863.py -q
"""
import sys
import pytest
from fastapi import HTTPException
from core.inference import mcp_client
from storage import mcp_servers_db
def _reset_db(tmp_path, monkeypatch):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(mcp_servers_db, "_schema_ready", False)
def _enable(monkeypatch):
monkeypatch.setenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "1")
def _disable(monkeypatch):
monkeypatch.delenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", raising = False)
# ── transport stub + recorder ───────────────────────────────────────
class _FakeTool:
def __init__(self, name):
self._name = name
def model_dump(self, exclude_none = True):
return {"name": self._name, "description": f"{self._name} tool"}
class _Block:
def __init__(self, text):
self.type = "text"
self.text = text
class _FakeResult:
is_error = False
def __init__(self, text):
self.content = [_Block(text)]
class _RecordingClient:
"""Stands in for fastmcp.Client; records that the transport was opened."""
def __init__(self, url, headers, use_oauth, recorder):
recorder.append({"url": url, "headers": headers, "use_oauth": use_oauth})
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def list_tools(self):
return [_FakeTool("list_directory"), _FakeTool("write_file")]
async def call_tool(self, name, args):
return _FakeResult(f"called {name}")
@pytest.fixture
def transport(monkeypatch):
"""Patch mcp_client._client with a recorder. Returns the recorder list;
empty == the stdio transport was never reached."""
recorder = []
monkeypatch.setattr(
mcp_client,
"_client",
lambda url, headers, use_oauth = False: _RecordingClient(
url, headers, use_oauth, recorder
),
)
return recorder
# ── 1. is_stdio ─────────────────────────────────────────────────────
@pytest.mark.parametrize(
"addr",
[
"http://localhost:8000/mcp",
"https://example.com/mcp",
" https://example.com/mcp ",
"HTTPS://EXAMPLE.COM/mcp",
],
)
def test_is_stdio_false_for_http(addr):
assert mcp_client.is_stdio(addr) is False
@pytest.mark.parametrize(
"addr",
[
"npx -y @modelcontextprotocol/server-filesystem /tmp",
"python -m some.module",
"uvx some-server --flag",
"/usr/local/bin/my-server",
],
)
def test_is_stdio_true_for_commands(addr):
assert mcp_client.is_stdio(addr) is True
# ── 2. parse_stdio_command ──────────────────────────────────────────
def test_parse_basic_argv():
assert mcp_client.parse_stdio_command(
"npx -y @modelcontextprotocol/server-filesystem /tmp"
) == ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
def test_parse_keeps_url_argument_as_one_command():
# gemini "high": a :// inside an ARGUMENT must not break the command.
assert mcp_client.parse_stdio_command(
"npx server --endpoint https://example.com/mcp"
) == ["npx", "server", "--endpoint", "https://example.com/mcp"]
def test_parse_quoted_arg():
assert mcp_client.parse_stdio_command('python -m mod --name "a b"') == [
"python",
"-m",
"mod",
"--name",
"a b",
]
def test_parse_empty_returns_empty_list():
assert mcp_client.parse_stdio_command(" ") == []
def test_parse_unclosed_quote_raises_valueerror():
with pytest.raises(ValueError):
mcp_client.parse_stdio_command('npx "unclosed')
def test_parse_windows_strips_wrapping_quotes(monkeypatch):
# gemini "medium": posix=False keeps backslash paths but also the wrapping
# quotes; the PR strips a matched pair so argv[0] reaches the OS clean.
monkeypatch.setattr(sys, "platform", "win32")
parts = mcp_client.parse_stdio_command(
r'"C:\Program Files\node\node.exe" server.js'
)
assert parts[0] == r"C:\Program Files\node\node.exe"
assert parts[1] == "server.js"
# ── 3. stdio_mcp_enabled ────────────────────────────────────────────
@pytest.mark.parametrize("val", ["0", "false", "true", "", " 1 ", "yes", "2"])
def test_stdio_disabled_for_non_exact_one(monkeypatch, val):
monkeypatch.setenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", val)
assert mcp_client.stdio_mcp_enabled() is False
def test_stdio_enabled_only_for_exact_one(monkeypatch):
_disable(monkeypatch)
assert mcp_client.stdio_mcp_enabled() is False
monkeypatch.setenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "1")
assert mcp_client.stdio_mcp_enabled() is True
# ── 4. probe_timeout ────────────────────────────────────────────────
def test_probe_timeout_matrix():
assert mcp_client.probe_timeout("https://x/mcp", False) == 8.0
assert mcp_client.probe_timeout("https://x/mcp", True) == 305.0
assert mcp_client.probe_timeout("npx server", False) == 60.0
# oauth wins regardless of address kind (documented behaviour)
assert mcp_client.probe_timeout("npx server", True) == 305.0
# ── 5. _validate_url gate ───────────────────────────────────────────
def test_validate_url_gate_off_rejects_stdio(monkeypatch):
_disable(monkeypatch)
from routes.mcp_servers import _validate_url
assert _validate_url("https://example.com/mcp") == "https://example.com/mcp"
for bad in ["npx server", "python -m mod", "ftp://host"]:
with pytest.raises(HTTPException) as exc:
_validate_url(bad)
assert exc.value.status_code == 400
def test_validate_url_gate_on_accepts_stdio(monkeypatch):
_enable(monkeypatch)
from routes.mcp_servers import _validate_url
assert _validate_url("npx -y server /tmp") == "npx -y server /tmp"
# http still works when stdio is on
assert _validate_url("https://x/mcp") == "https://x/mcp"
# url-bearing argument accepted as a command
assert _validate_url("npx server --url https://x/mcp") == (
"npx server --url https://x/mcp"
)
# empty / unparseable still rejected
for bad in [" ", '"unclosed']:
with pytest.raises(HTTPException) as exc:
_validate_url(bad)
assert exc.value.status_code == 400
# ── 6. gate enforcement at every spawn path (mocked transport) ──────
def test_create_route_gate(tmp_path, monkeypatch, transport):
import asyncio
from models.mcp_servers import McpServerCreate
import routes.mcp_servers as routes_mcp
_reset_db(tmp_path, monkeypatch)
payload = McpServerCreate(display_name = "FS", url = "npx -y server /tmp")
_disable(monkeypatch)
with pytest.raises(HTTPException) as exc:
asyncio.run(routes_mcp.create_mcp_server(payload, current_subject = "u"))
assert exc.value.status_code == 400
_enable(monkeypatch)
resp = asyncio.run(routes_mcp.create_mcp_server(payload, current_subject = "u"))
assert resp.url == "npx -y server /tmp"
def test_update_http_to_stdio_blocked_when_off(tmp_path, monkeypatch):
import asyncio
from models.mcp_servers import McpServerUpdate
import routes.mcp_servers as routes_mcp
_reset_db(tmp_path, monkeypatch)
_disable(monkeypatch)
mcp_servers_db.create_server(id = "s1", display_name = "A", url = "https://a/mcp")
# editing url -> stdio command must 400 (http->stdio edit bypass closed)
with pytest.raises(HTTPException) as exc:
asyncio.run(
routes_mcp.update_mcp_server(
"s1", McpServerUpdate(url = "npx server"), current_subject = "u"
)
)
assert exc.value.status_code == 400
def test_test_route_gate(tmp_path, monkeypatch, transport):
import asyncio
from models.mcp_servers import McpServerTestRequest
import routes.mcp_servers as routes_mcp
_reset_db(tmp_path, monkeypatch)
req = McpServerTestRequest(url = "npx -y server /tmp")
_disable(monkeypatch)
with pytest.raises(HTTPException) as exc:
asyncio.run(routes_mcp.test_mcp_server(req, current_subject = "u"))
assert exc.value.status_code == 400
assert transport == [] # transport never opened
_enable(monkeypatch)
res = asyncio.run(routes_mcp.test_mcp_server(req, current_subject = "u"))
assert res.ok and res.tool_count == 2
assert len(transport) == 1
def test_refresh_route_gate(tmp_path, monkeypatch, transport):
import asyncio
import routes.mcp_servers as routes_mcp
_reset_db(tmp_path, monkeypatch)
# a stdio row as if carried over from a desktop DB
mcp_servers_db.create_server(id = "stdio1", display_name = "FS", url = "npx server")
_disable(monkeypatch)
with pytest.raises(HTTPException) as exc:
asyncio.run(routes_mcp.refresh_mcp_server_tools("stdio1", current_subject = "u"))
assert exc.value.status_code == 400
assert transport == []
_enable(monkeypatch)
res = asyncio.run(
routes_mcp.refresh_mcp_server_tools("stdio1", current_subject = "u")
)
assert res.ok and res.tool_count == 2
assert len(transport) == 1
def test_discovery_gate(tmp_path, monkeypatch, transport):
import asyncio
from core.inference.tools import get_enabled_mcp_tools
_reset_db(tmp_path, monkeypatch)
mcp_servers_db.create_server(
id = "stdio1", display_name = "FS", url = "npx server", is_enabled = True
)
_disable(monkeypatch)
assert asyncio.run(get_enabled_mcp_tools()) == []
assert transport == [] # filtered out before any probe
_enable(monkeypatch)
specs = asyncio.run(get_enabled_mcp_tools())
assert len(specs) == 2
assert len(transport) == 1
def test_execute_gate(tmp_path, monkeypatch, transport):
from core.inference.tools import execute_tool
_reset_db(tmp_path, monkeypatch)
mcp_servers_db.create_server(
id = "stdio1", display_name = "FS", url = "npx server", is_enabled = True
)
_disable(monkeypatch)
out = execute_tool("mcp__stdio1__list_directory", {"path": "/tmp"})
assert "disabled on this host" in out
assert transport == []
_enable(monkeypatch)
out = execute_tool("mcp__stdio1__list_directory", {"path": "/tmp"})
assert out == "called list_directory"
assert len(transport) == 1
# ── 7. env vars ride headers_json as the subprocess env ─────────────
def test_stdio_env_passed_through(tmp_path, monkeypatch, transport):
from core.inference.tools import execute_tool
_reset_db(tmp_path, monkeypatch)
_enable(monkeypatch)
mcp_servers_db.create_server(
id = "stdio1",
display_name = "FS",
url = "npx server",
headers_json = '{"API_KEY": "sk-test"}',
is_enabled = True,
)
execute_tool("mcp__stdio1__list_directory", {})
assert transport[-1]["headers"] == {"API_KEY": "sk-test"}

View file

@ -28,12 +28,14 @@ Edge cases under coverage:
"""
import threading
from typing import cast
import pytest
from core.inference import safetensors_agentic
from core.inference.safetensors_agentic import (
_coerce_arguments,
_detect_render_html_tool_start,
run_safetensors_tool_loop,
)
from core.inference.tool_call_parser import (
@ -97,6 +99,17 @@ class TestParser:
assert len(result) == 1
assert "print('hi')" in result[0]["function"]["arguments"]
def test_function_signal_inside_parameter_is_literal(self):
text = (
"<function=python>"
"<parameter=code>print('<function=render_html>')</parameter>"
"</function>"
)
result = parse_tool_calls_from_text(text)
assert len(result) == 1
assert result[0]["function"]["name"] == "python"
assert "<function=render_html>" in result[0]["function"]["arguments"]
def test_multiple_calls(self):
text = (
'<tool_call>{"name":"web_search","arguments":{"query":"a"}}</tool_call>'
@ -118,6 +131,18 @@ class TestParser:
assert has_tool_signal("hi <function=foo>...")
assert not has_tool_signal("hello world")
def test_render_html_start_detector_uses_first_tool(self):
assert _detect_render_html_tool_start("<function=render_html>")
assert _detect_render_html_tool_start(
'<tool_call>{"name":"render_html","arguments":{"code":"<html>"}'
)
assert not _detect_render_html_tool_start(
"<function=python><parameter=code>'<function=render_html>'"
)
assert not _detect_render_html_tool_start(
'<tool_call>{"name":"python","arguments":{"code":"<function=render_html>"}}'
)
def test_strip_markup_closed(self):
text = "before <tool_call>{}</tool_call> after"
assert strip_tool_markup(text) == "before after"
@ -280,6 +305,104 @@ class TestLoopBasic:
contents = [e for e in events if e["type"] == "content"]
assert "Result: 1" in contents[-1]["text"]
def test_render_html_emits_provisional_tool_start(self):
exec_fn = FakeExecuteTool(["Rendered HTML artifact."])
turn_iter = iter(
[
[
"<function=render_html>",
"<parameter=code><!doctype html><html>",
"<body>Hi</body></html></parameter></function>",
],
["Done."],
]
)
def _gen(_messages):
chunks = next(turn_iter)
acc = ""
for chunk in chunks:
acc += chunk
yield acc
loop = run_safetensors_tool_loop(
single_turn = _gen,
messages = [{"role": "user", "content": "make html"}],
tools = [{"type": "function", "function": {"name": "render_html"}}],
execute_tool = exec_fn,
)
events = _collect_events(loop)
tool_starts = [e for e in events if e["type"] == "tool_start"]
assert len(tool_starts) == 2
assert tool_starts[0]["tool_name"] == "render_html"
assert tool_starts[0]["arguments"] == {}
assert tool_starts[1]["tool_name"] == "render_html"
assert "<!doctype html>" in tool_starts[1]["arguments"]["code"]
assert exec_fn.calls[0][0] == "render_html"
assert "<!doctype html>" in exec_fn.calls[0][1]["code"]
def test_python_tool_containing_render_html_signal_does_not_emit_provisional_start(
self,
):
loop, exec_fn = _make_loop(
turns = [
[
"<function=python>",
"<parameter=code>print('<function=render_html>')",
"</parameter></function>",
],
["Done."],
],
exec_results = ["ok"],
)
events = _collect_events(loop)
tool_starts = [e for e in events if e["type"] == "tool_start"]
assert len(tool_starts) == 1
assert tool_starts[0]["tool_name"] == "python"
assert exec_fn.calls == [
("python", {"code": "print('<function=render_html>')"})
]
def test_render_html_success_blocks_second_artifact_call(self):
exec_fn = FakeExecuteTool(["Rendered HTML artifact."])
turn_iter = iter(
[
[
'<tool_call>{"name":"render_html",',
'"arguments":{"code":"<html>one</html>"}}',
],
[
'<tool_call>{"name":"render_html",',
'"arguments":{"code":"<html>two</html>"}}',
],
["Done."],
]
)
def _gen(_messages):
chunks = next(turn_iter)
acc = ""
for chunk in chunks:
acc += chunk
yield acc
loop = run_safetensors_tool_loop(
single_turn = _gen,
messages = [{"role": "user", "content": "make html"}],
tools = [{"type": "function", "function": {"name": "render_html"}}],
execute_tool = exec_fn,
)
events = _collect_events(loop)
tool_starts = [e for e in events if e["type"] == "tool_start"]
assert exec_fn.calls == [("render_html", {"code": "<html>one</html>"})]
assert [e["arguments"] for e in tool_starts] == [
{},
{"code": "<html>one</html>"},
]
def test_truncated_unclosed_tool_call(self):
loop, exec_fn = _make_loop(
turns = [
@ -595,6 +718,7 @@ class TestChatTemplateHelper:
tok = self._Tok({"tools", "enable_thinking"})
self.apply(tok, [], tools = [{}], enable_thinking = True)
assert tok.call_count == 1
assert tok.last_kwargs is not None
assert "tools" in tok.last_kwargs
assert "enable_thinking" in tok.last_kwargs
@ -781,7 +905,7 @@ class TestGptOssNameDetection:
def test_empty_or_none_returns_false(self):
assert is_gpt_oss_model_name("") is False
assert is_gpt_oss_model_name(None) is False
assert is_gpt_oss_model_name(cast(str, None)) is False
if __name__ == "__main__":

View file

@ -3,18 +3,19 @@
"use client";
import { ArtifactCard, useChatRuntimeStore } from "@/features/chat";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { preprocessLaTeX } from "@/lib/latex";
import { openLink } from "@/lib/open-link";
import { INTERNAL, useMessagePartText } from "@assistant-ui/react";
import { INTERNAL, useAuiState, useMessagePartText } from "@assistant-ui/react";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { createCodePlugin } from "./code-plugin";
import { createMathPlugin } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { DownloadIcon, Maximize2Icon, Minimize2Icon } from "lucide-react";
import { DownloadIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Block, type BlockProps, Streamdown } from "streamdown";
import { createCodePlugin } from "./code-plugin";
import "katex/dist/katex.min.css";
import { AudioPlayer } from "./audio-player";
import { unslothDarkTheme, unslothLightTheme } from "./code-themes";
@ -26,11 +27,7 @@ const code = createCodePlugin({
const { withSmoothContextProvider } = INTERNAL;
const STREAMDOWN_COMPONENTS = {
a: ({
href,
children,
...props
}: React.ComponentProps<"a">) => (
a: ({ href, children, ...props }: React.ComponentProps<"a">) => (
<a
href={href}
rel="noopener noreferrer"
@ -59,6 +56,34 @@ type CodeFence = {
source: string;
};
type ToolCallPartLike = {
type?: string;
toolName?: string;
args?: unknown;
result?: unknown;
};
function isRenderableRenderHtmlToolPart(part: unknown): boolean {
const toolPart = part as ToolCallPartLike;
if (toolPart.type !== "tool-call" || toolPart.toolName !== "render_html") {
return false;
}
if (
typeof toolPart.result === "string" &&
toolPart.result.startsWith("Error:")
) {
return false;
}
if (
typeof toolPart.result === "string" &&
toolPart.result.startsWith("Rendered HTML artifact")
) {
return true;
}
const args = toolPart.args as { code?: unknown } | undefined;
return typeof args?.code === "string" && args.code.trim().length > 0;
}
function getMermaidSource(blockContent: string): string | null {
const source = blockContent.match(MERMAID_SOURCE_RE)?.[1]?.trim();
return source && source.length > 0 ? source : null;
@ -123,7 +148,13 @@ function isHtmlFence(codeFence: CodeFence): boolean {
return lang === "html" && !isSvgFence(codeFence);
}
const UNSAFE_SVG_RE = /<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
function isFullHtmlDocument(source: string): boolean {
const trimmed = source.trimStart();
return /^<!doctype\s+html\b/i.test(trimmed) || /^<html[\s>]/i.test(trimmed);
}
const UNSAFE_SVG_RE =
/<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
function sanitizeSvg(source: string): string | null {
if (UNSAFE_SVG_RE.test(source)) return null;
@ -145,96 +176,6 @@ function SvgPreview({ source }: { source: string }) {
);
}
const HTML_PREVIEW_DEFAULT_HEIGHT = 400;
const HTML_PREVIEW_MAX_HEIGHT = 800;
function HtmlPreview({ source }: { source: string }) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(HTML_PREVIEW_DEFAULT_HEIGHT);
const [enlarged, setEnlarged] = useState(false);
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.source !== iframeRef.current?.contentWindow) return;
if (typeof e.data?.htmlPreviewHeight === "number") {
setHeight(Math.min(Math.max(e.data.htmlPreviewHeight, 100), HTML_PREVIEW_MAX_HEIGHT));
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, []);
useEffect(() => {
if (!enlarged) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setEnlarged(false);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [enlarged]);
const resizeScript = `<script>new ResizeObserver(()=>{
parent.postMessage({htmlPreviewHeight:document.documentElement.scrollHeight},"*");
}).observe(document.documentElement);</script>`;
const srcDoc = source + resizeScript;
if (enlarged) {
return (
<>
<div className="mt-2 overflow-hidden rounded-lg border border-border" style={{ height }}>
{/* Placeholder keeps layout stable while overlay is shown */}
</div>
<div
className="fixed inset-0 z-50 flex flex-col bg-background/80 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) setEnlarged(false); }}
>
<div className="flex items-center justify-end gap-2 px-4 py-2">
<button
type="button"
className="flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => setEnlarged(false)}
title="Exit fullscreen (Esc)"
>
<Minimize2Icon className="size-4" />
Exit fullscreen
</button>
</div>
<div className="mx-4 mb-4 flex-1 overflow-hidden rounded-lg border border-border bg-background">
<iframe
ref={iframeRef}
srcDoc={srcDoc}
sandbox="allow-scripts"
style={{ width: "100%", height: "100%", border: "none", display: "block" }}
title="HTML preview"
/>
</div>
</div>
</>
);
}
return (
<div className="group/html-preview relative mt-2 overflow-hidden rounded-lg border border-border">
<button
type="button"
className="absolute top-2 right-2 z-10 rounded-md border border-border bg-background/80 p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-muted hover:text-foreground group-hover/html-preview:opacity-100 supports-[backdrop-filter]:backdrop-blur"
onClick={() => setEnlarged(true)}
title="Enlarge preview"
>
<Maximize2Icon className="size-4" />
</button>
<iframe
ref={iframeRef}
srcDoc={srcDoc}
sandbox="allow-scripts"
style={{ width: "100%", height, border: "none", display: "block" }}
title="HTML preview"
/>
</div>
);
}
function downloadTextFile(filename: string, text: string): void {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
@ -346,6 +287,12 @@ function CodeBlockActions({
}
function StreamdownBlock(props: BlockProps) {
const shouldCollapseHtmlArtifacts = useChatRuntimeStore(
(state) => state.artifactsEnabled || state.collapseHtmlArtifacts,
);
const messageHasRenderableRenderHtmlTool = useAuiState(({ message }) =>
message.parts.some(isRenderableRenderHtmlToolPart),
);
const hasMermaidFence = props.content.includes("```mermaid");
const mermaidSource = getMermaidSource(props.content);
const codeFence = getCodeFence(props.content);
@ -362,7 +309,9 @@ function StreamdownBlock(props: BlockProps) {
return (
<div className="relative isolate">
<div className="my-4 rounded-xl border border-border bg-muted/30 p-4">
<div className="mb-2 text-xs font-medium text-muted-foreground">svg</div>
<div className="mb-2 text-xs font-medium text-muted-foreground">
svg
</div>
<pre className="overflow-x-auto text-xs text-muted-foreground whitespace-pre-wrap break-all">
<code>{codeFence.source}</code>
</pre>
@ -371,10 +320,17 @@ function StreamdownBlock(props: BlockProps) {
);
}
if (props.isIncomplete && codeFence && isHtmlFence(codeFence)) {
if (
shouldCollapseHtmlArtifacts &&
!messageHasRenderableRenderHtmlTool &&
props.isIncomplete &&
codeFence &&
isHtmlFence(codeFence) &&
isFullHtmlDocument(codeFence.source)
) {
return (
<div className="my-4 flex h-48 items-center justify-center rounded-xl border border-border bg-muted/30 text-sm text-muted-foreground animate-pulse">
Loading preview...
Loading artifact preview...
</div>
);
}
@ -389,8 +345,24 @@ function StreamdownBlock(props: BlockProps) {
}
if (codeFence) {
const svgSource = !props.isIncomplete && isSvgFence(codeFence) ? sanitizeSvg(codeFence.source) : null;
const htmlSource = !props.isIncomplete && isHtmlFence(codeFence) ? codeFence.source : null;
const svgSource =
!props.isIncomplete && isSvgFence(codeFence)
? sanitizeSvg(codeFence.source)
: null;
const htmlSource =
shouldCollapseHtmlArtifacts &&
!messageHasRenderableRenderHtmlTool &&
!props.isIncomplete &&
isHtmlFence(codeFence) &&
isFullHtmlDocument(codeFence.source)
? codeFence.source
: null;
if (htmlSource) {
return (
<ArtifactCard code={htmlSource} title="HTML preview" source="fence" />
);
}
return (
<>
<div className="relative isolate">
@ -402,7 +374,6 @@ function StreamdownBlock(props: BlockProps) {
/>
</div>
{svgSource && <SvgPreview source={svgSource} />}
{htmlSource && <HtmlPreview source={htmlSource} />}
</>
);
}

View file

@ -17,7 +17,7 @@ import {
CloudIcon,
DashboardSquare01Icon,
FolderSearchIcon,
Logout01Icon,
RemoveCircleIcon,
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
@ -326,7 +326,7 @@ function ModelSelectorContent({
className="flex w-full items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-destructive transition-colors hover:bg-destructive/10"
title="Eject model"
>
<HugeiconsIcon icon={Logout01Icon} className="size-3.5" />
<HugeiconsIcon icon={RemoveCircleIcon} className="size-3.5" />
Eject loaded model
</button>
</div>

View file

@ -263,20 +263,34 @@ const SourcesGroup: FC = () => {
return (
<div className="relative mt-2 mb-3">
{/* Hidden measurement container — renders all badges to measure row positions */}
{/* Hidden measurement container. Renders all badges off-screen so we
can read each child's offsetTop and decide how many fit in two
rows. Wrapped in an absolute, h-0, overflow-hidden box so the
measurement pills do NOT contribute to the viewport's scrollable
overflow region. Without this clip, every hidden source row
adds ~30px to scrollHeight, producing a phantom empty scroll
area below the message: visible to users as unbounded blank
space below the assistant action bar. The inner div still
flex-wraps its children for measurement; offsetTop reads
correctly because the wrapper is positioned (absolute) and the
children's offsetTop is measured relative to it. */}
<div
ref={containerRef}
aria-hidden
className="flex w-full flex-wrap gap-1 invisible absolute pointer-events-none"
className="absolute pointer-events-none overflow-hidden h-0 w-full left-0 top-0"
>
{sources.map((source) => (
<span key={source.id} className="inline-block">
<Source href={source.url}>
<SourceIcon url={source.url} />
<SourceTitle>{source.title || extractDomain(source.url)}</SourceTitle>
</Source>
</span>
))}
<div
ref={containerRef}
className="flex w-full flex-wrap gap-1 invisible"
>
{sources.map((source) => (
<span key={source.id} className="inline-block">
<Source href={source.url}>
<SourceIcon url={source.url} />
<SourceTitle>{source.title || extractDomain(source.url)}</SourceTitle>
</Source>
</span>
))}
</div>
</div>
{/* Visible container */}

View file

@ -24,6 +24,7 @@ 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 { ImageGenerationToolUI } from "@/components/assistant-ui/tool-ui-image-generation";
import { RenderHtmlToolUI } from "@/components/assistant-ui/tool-ui-render-html";
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";
@ -73,6 +74,7 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
DownloadIcon,
FileTextIcon,
GlobeIcon,
HeadphonesIcon,
LightbulbIcon,
@ -1117,6 +1119,29 @@ const ImagesToggle: FC = () => {
);
};
const ArtifactsToggle: FC = () => {
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
);
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled);
const disabled = !modelLoaded;
return (
<button
type="button"
disabled={disabled}
onClick={() => setArtifactsEnabled(!artifactsEnabled)}
className="composer-pill-btn"
data-active={artifactsEnabled && !disabled ? "true" : "false"}
aria-label={artifactsEnabled ? "Disable artifacts" : "Enable artifacts"}
>
<FileTextIcon className="size-3.5" />
<span>Artifacts</span>
</button>
);
};
const ToolStatusDisplay: FC = () => {
const toolStatus = useChatRuntimeStore((s) => s.toolStatus);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
@ -1189,6 +1214,7 @@ const ComposerAction: FC<{
<WebSearchToggle />
<CodeToolsToggle />
<ImagesToggle />
<ArtifactsToggle />
</div>
<div className="flex items-center gap-1">
<ComposerPrimitive.If dictation={false}>
@ -1316,6 +1342,7 @@ const AssistantMessage: FC = () => {
terminal: TerminalToolUI,
code_execution: CodeExecutionToolUI,
image_generation: ImageGenerationToolUI,
render_html: RenderHtmlToolUI,
},
Fallback: ToolFallback,
},

View file

@ -8,6 +8,7 @@ import {
type FC,
type PropsWithChildren,
} from "react";
import { useAuiState } from "@assistant-ui/react";
import { ChevronDownIcon, LoaderIcon } from "lucide-react";
import { Wrench01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
@ -27,7 +28,8 @@ const toolGroupVariants = cva("aui-tool-group-root group/tool-group w-full", {
variant: {
outline: "corner-squircle rounded-lg border py-3",
ghost: "rounded-lg bg-muted/10 py-2",
muted: "corner-squircle rounded-lg border border-muted-foreground/30 bg-muted/30 py-3",
muted:
"corner-squircle rounded-lg border border-muted-foreground/30 bg-muted/30 py-3",
},
},
defaultVariants: { variant: "ghost" },
@ -209,9 +211,17 @@ const ToolGroupImpl: FC<
PropsWithChildren<{ startIndex: number; endIndex: number }>
> = ({ children, startIndex, endIndex }) => {
const toolCount = endIndex - startIndex + 1;
const containsArtifactTool = useAuiState(({ message }) =>
message.parts
.slice(startIndex, endIndex + 1)
.some(
(part) => part.type === "tool-call" && part.toolName === "render_html",
),
);
// Single tool call — render directly without wrapper
if (toolCount <= 1) {
// Single tool calls and artifacts render directly so cards never hide inside
// a collapsed tool group.
if (toolCount <= 1 || containsArtifactTool) {
return <>{children}</>;
}

View file

@ -0,0 +1,142 @@
// 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 {
ArtifactCard,
useChatArtifactsStore,
useSelectedChatArtifact,
} from "@/features/chat";
import {
type ToolCallMessagePartComponent,
useAuiState,
useToolArgsStatus,
} from "@assistant-ui/react";
import { BrowserIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { memo, useEffect } from "react";
// Context7 assistant-ui docs: tool UIs can read streaming args via
// useToolArgsStatus, so render_html does not need to wait for tool completion.
type RenderHtmlArgs = Record<string, unknown> & {
code?: string;
title?: string;
};
const RENDER_HTML_SESSION_STARTED_AT = Date.now();
const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({
args,
result,
status,
toolCallId,
}) => {
const { propStatus } = useToolArgsStatus<RenderHtmlArgs>();
const parsedArgs = (args as RenderHtmlArgs) ?? {};
const code = typeof parsedArgs.code === "string" ? parsedArgs.code : "";
const hasCode = code.trim().length > 0;
const title =
typeof parsedArgs.title === "string" ? parsedArgs.title : "HTML artifact";
const isRunning = status?.type === "running";
const codeIsStreaming = propStatus.code === "streaming";
// Surface the backend error when the tool call completed with invalid
// args. Backend success results start with "Rendered HTML artifact";
// error results start with "Error:".
const errorText =
status?.type === "complete" &&
typeof result === "string" &&
result.startsWith("Error:")
? result
: null;
const messageId = useAuiState(({ message }) => message.id) ?? null;
const isMessageRunning = useAuiState(
({ message }) => message.status?.type === "running",
);
const messageCreatedAtMs = useAuiState(({ message }) =>
message.createdAt instanceof Date ? message.createdAt.getTime() : null,
);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
const isLiveGeneratingArtifact =
isThreadRunning && isMessageRunning && (isRunning || codeIsStreaming);
const isStaleGeneratingArtifact =
!(isThreadRunning && isMessageRunning) && (isRunning || codeIsStreaming);
const messageCreatedThisSession =
messageCreatedAtMs != null &&
messageCreatedAtMs >= RENDER_HTML_SESSION_STARTED_AT - 1000;
const shouldAutoOpenArtifact =
(isLiveGeneratingArtifact && (hasCode || isRunning || codeIsStreaming)) ||
(hasCode && messageCreatedThisSession);
const selectedArtifact = useSelectedChatArtifact();
const closeArtifactSurface = useChatArtifactsStore(
(state) => state.closeArtifactSurface,
);
useEffect(() => {
if (!errorText) {
return;
}
if (!(messageId && toolCallId)) {
return;
}
if (selectedArtifact?.sourceToolCallId !== toolCallId) {
return;
}
if (selectedArtifact?.sourceMessageId !== messageId) {
return;
}
closeArtifactSurface();
}, [
closeArtifactSurface,
errorText,
messageId,
selectedArtifact,
toolCallId,
]);
if (hasCode || (isLiveGeneratingArtifact && !errorText)) {
return (
<ArtifactCard
code={code}
title={title}
source="tool"
sourceToolCallId={toolCallId}
autoOpen={!errorText && shouldAutoOpenArtifact}
isStreaming={!errorText && isLiveGeneratingArtifact}
/>
);
}
return (
<div className="relative my-2 flex min-h-[52px] w-full max-w-md items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left dark:bg-muted/10">
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
<HugeiconsIcon
icon={BrowserIcon}
strokeWidth={1.75}
className="size-5 shrink-0 text-muted-foreground"
/>
<span className="grid min-w-0 flex-1 gap-1">
<span className="truncate text-sm font-medium leading-tight text-foreground">
{errorText
? "Artifact error"
: isStaleGeneratingArtifact
? "Artifact interrupted"
: "Artifact unavailable"}
</span>
<span className="truncate text-[11px] leading-none text-muted-foreground">
{errorText ??
(isStaleGeneratingArtifact
? "Refresh stopped this preview"
: "HTML artifact")}
</span>
</span>
</div>
</div>
);
};
export const RenderHtmlToolUI = memo(
RenderHtmlToolUIImpl,
) as unknown as ToolCallMessagePartComponent;
RenderHtmlToolUI.displayName = "RenderHtmlToolUI";

View file

@ -1,54 +1,54 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import type * as React from "react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Group>): React.ReactElement {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>): React.ReactElement {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean;
}): React.ReactElement {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border h-6 w-1 rounded-lg z-10 flex shrink-0" />
)}
</ResizablePrimitive.Separator>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
import type * as React from "react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Group>): React.ReactElement {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>): React.ReactElement {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean;
}): React.ReactElement {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"group bg-border/80 relative z-10 flex w-px cursor-col-resize items-center justify-center transition-[background-color,box-shadow] duration-150 ease-out after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2 hover:bg-primary/80 hover:shadow-[0_0_16px_rgba(23,184,139,0.55)] active:bg-primary/90 active:shadow-[0_0_18px_rgba(23,184,139,0.7)] focus-visible:bg-primary/80 focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:cursor-row-resize data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-2 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border transition-[background-color,box-shadow,transform] duration-150 ease-out group-hover:scale-y-110 group-hover:bg-primary/80 group-hover:shadow-[0_0_12px_rgba(23,184,139,0.65)] group-active:bg-primary" />
)}
</ResizablePrimitive.Separator>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View file

@ -19,7 +19,6 @@ import {
} from "../external-providers";
import { pickFriendlyContainerName } from "../lib/friendly-names";
import {
EXTERNAL_MAX_OUTPUT_TOKENS,
clampReasoningEffortToLevels,
getExternalMaxOutputTokens,
getExternalMinOutputTokens,
@ -46,12 +45,12 @@ import type {
OpenAIReasoningContentPart,
} from "../types/api";
import type { ChatModelSummary } from "../types/runtime";
import { getImageInputUnavailableReason } from "../utils/image-input-support";
import {
getStoredChatThread,
listStoredChatThreads,
updateStoredChatThread,
} from "../utils/chat-history-storage";
import { getImageInputUnavailableReason } from "../utils/image-input-support";
import {
hasClosedThinkTag,
parseAssistantContent,
@ -775,36 +774,6 @@ function toOpenAIMessages(message: RunMessage): SerializedMessage[] {
return toolResults.length > 0 ? [base, ...toolResults] : [base];
}
// Thin singular wrapper: returns only the first serialized message
// (without tool_calls or tool follow-ups) so the OpenAI image-edit
// replay path can map a thread to flat OpenAI chat messages without
// pulling in tool history.
function toOpenAIMessage(message: RunMessage): {
role: "system" | "user" | "assistant";
content: OpenAIMessageContent;
} | null {
const serialized = toOpenAIMessages(message);
if (serialized.length === 0) return null;
const first = serialized[0];
if (
first.role !== "system" &&
first.role !== "user" &&
first.role !== "assistant"
) {
return null;
}
if (first.content === null || first.content === undefined) {
return null;
}
if (typeof first.content === "string" && !first.content) {
return null;
}
return {
role: first.role,
content: first.content as OpenAIMessageContent,
};
}
function extractImageBase64(input: string): string | undefined {
if (!input) {
return undefined;
@ -1303,6 +1272,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
toolsEnabled,
codeToolsEnabled,
imageToolsEnabled,
artifactsEnabled,
mcpEnabledForChat,
webFetchToolsEnabled,
} = runtime;
@ -1537,36 +1507,62 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
"Do not return tool-call syntax inside your response.";
}
}
if (disabledToolGuard) {
const firstMessage = outboundMessages[0];
type OutboundMessage = (typeof outboundMessages)[number];
function addSystemInstruction(
targetMessages: OutboundMessage[],
text: string | null,
): void {
if (!text) return;
const firstMessage = targetMessages[0];
if (firstMessage?.role === "system") {
if (typeof firstMessage.content === "string") {
outboundMessages[0] = {
targetMessages[0] = {
...firstMessage,
content: `${firstMessage.content}\n\n${disabledToolGuard}`,
content: `${firstMessage.content}\n\n${text}`,
};
} else {
outboundMessages[0] = {
targetMessages[0] = {
...firstMessage,
content: [
...(Array.isArray(firstMessage.content)
? firstMessage.content
: []),
{ type: "text", text: `\n\n${disabledToolGuard}` },
{ type: "text", text: `\n\n${text}` },
],
};
}
} else {
outboundMessages.unshift({
role: "system",
content: disabledToolGuard,
});
return;
}
targetMessages.unshift({ role: "system", content: text });
}
// Scan post-prune history so a refused user turn's image/audio
// doesn't gate or mis-attribute the next non-refused turn.
const imageBase64 = findLatestUserImageBase64(survivingMessages);
const audioBase64 = findLatestUserAudioBase64(survivingMessages);
const hasOutboundImage = Boolean(imageBase64);
// Keep render_html local-only and mirror the backend image-turn gate.
// Artifacts are independent of Search/Code: if a local tool-capable
// model has Artifacts enabled, expose render_html even when no other
// tool pills are active.
const renderHtmlToolEnabledForThisTurn = Boolean(
!isExternalRequest &&
supportsTools &&
artifactsEnabled &&
!hasOutboundImage,
);
const artifactInstruction = artifactsEnabled
? renderHtmlToolEnabledForThisTurn
? "When the user asks for an HTML, CSS, or JavaScript artifact, call render_html once with one complete self-contained HTML document in the code argument. Embed CSS and JavaScript inside the document. After render_html succeeds, do not call it again in the same response unless the user asks for changes. Future user requests for new artifacts may call render_html once."
: "When the user asks for an HTML, CSS, or JavaScript artifact, return one complete self-contained fenced html code block. Embed CSS and JavaScript inside the document. Do not emit tool-call syntax."
: null;
const effectiveDisabledToolGuard =
disabledToolGuard && artifactsEnabled
? `${disabledToolGuard} HTML, CSS, or JavaScript artifact requests can still be answered by following the artifact fallback instruction.`
: disabledToolGuard;
addSystemInstruction(outboundMessages, effectiveDisabledToolGuard);
addSystemInstruction(outboundMessages, artifactInstruction);
// Block when ANY image is in the outbound payload (current or
// prior turns) and the loaded model can't process images. Keeps
@ -2093,12 +2089,19 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
...(supportsPreserveThinking
? { preserve_thinking: preserveThinking }
: {}),
...(supportsTools && (toolsEnabled || codeToolsEnabled || mcpEnabledForChat)
...(supportsTools &&
(toolsEnabled ||
codeToolsEnabled ||
renderHtmlToolEnabledForThisTurn ||
mcpEnabledForChat)
? {
enable_tools: true,
enabled_tools: [
...(toolsEnabled ? ["web_search"] : []),
...(codeToolsEnabled ? ["python", "terminal"] : []),
...(renderHtmlToolEnabledForThisTurn
? ["render_html"]
: []),
],
mcp_enabled: mcpEnabledForChat,
auto_heal_tool_calls:
@ -2205,13 +2208,25 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
`${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,
});
const idx = toolCallParts.findIndex(
(p) => p.toolCallId === id,
);
if (idx !== -1) {
toolCallParts[idx] = {
...toolCallParts[idx],
toolName: toolEvent.tool_name as string,
argsText: JSON.stringify(toolArgs),
args: toolArgs,
};
} else {
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) ||

View file

@ -23,6 +23,8 @@ export interface PersistedChatSettings {
autoTitle?: boolean;
reasoningEffort?: ReasoningEffort;
preserveThinking?: boolean;
collapseHtmlArtifacts?: boolean;
allowArtifactNetworkAccess?: boolean;
autoHealToolCalls?: boolean;
maxToolCallsPerMessage?: number;
toolCallTimeout?: number;

View file

@ -0,0 +1,141 @@
// 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 { cn } from "@/lib/utils";
import { useAuiState } from "@assistant-ui/react";
import { LayoutTwoColumnIcon as Layout2ColumnIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useLayoutEffect, useMemo } from "react";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import {
hasAutoOpenedArtifact,
rememberAutoOpenedArtifact,
useChatArtifactsStore,
} from "./store";
import {
type ChatArtifact,
type ChatArtifactSource,
createChatArtifact,
} from "./types";
export function ArtifactCard({
code,
title,
source,
sourceToolCallId,
sourceMessageId,
className,
autoOpen = false,
isStreaming = false,
}: {
code: string;
title?: string | null;
source: ChatArtifactSource;
sourceToolCallId?: string | null;
sourceMessageId?: string | null;
className?: string;
autoOpen?: boolean;
isStreaming?: boolean;
}) {
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
const messageIdFromContext = useAuiState(({ message }) => message.id);
const threadIdFromContext = useAuiState(
({ threads }) => threads.mainThreadId,
);
const artifactThreadId = threadIdFromContext ?? activeThreadId ?? null;
const openArtifact = useChatArtifactsStore((state) => state.openArtifact);
const updateArtifact = useChatArtifactsStore((state) => state.updateArtifact);
const selectedArtifactId = useChatArtifactsStore(
(state) => state.selectedArtifactId,
);
const artifact = useMemo<ChatArtifact>(
() =>
createChatArtifact({
code,
title,
source,
sourceMessageId: sourceMessageId ?? messageIdFromContext ?? null,
sourceToolCallId: sourceToolCallId ?? null,
threadId: artifactThreadId,
isStreaming,
}),
[
artifactThreadId,
code,
isStreaming,
messageIdFromContext,
source,
sourceMessageId,
sourceToolCallId,
title,
],
);
const surface = artifactThreadId ? "panel" : "overlay";
useLayoutEffect(() => {
if (selectedArtifactId === artifact.id) {
updateArtifact(artifact);
}
if (!autoOpen) {
return;
}
if (hasAutoOpenedArtifact(artifact.id)) {
return;
}
rememberAutoOpenedArtifact(artifact.id);
openArtifact(artifact, { surface });
}, [
artifact,
autoOpen,
openArtifact,
selectedArtifactId,
surface,
updateArtifact,
]);
return (
<button
type="button"
className={cn(
"group/artifact-card relative my-2 flex min-h-[52px] w-full max-w-md cursor-pointer items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left transition-colors hover:bg-muted/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"dark:bg-muted/10 dark:hover:bg-muted/20",
isStreaming &&
"border-border/80 bg-muted/20 dark:border-border/70 dark:bg-muted/15",
className,
)}
onClick={() => openArtifact(artifact, { surface })}
aria-label={`Open ${artifact.title}`}
>
{isStreaming ? (
<span
aria-hidden={true}
className="artifact-card-shimmer pointer-events-none absolute inset-0 z-0 motion-reduce:hidden"
/>
) : null}
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
<HugeiconsIcon
icon={Layout2ColumnIcon}
strokeWidth={1.75}
className="size-5 shrink-0 text-muted-foreground"
/>
<span className="grid min-w-0 flex-1 gap-1">
<span className="truncate text-sm font-medium leading-tight text-foreground">
{artifact.title}
</span>
<span className="truncate text-[11px] leading-none text-muted-foreground">
HTML artifact
</span>
</span>
{isStreaming ? (
<span className="shimmer shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary motion-reduce:animate-none">
Generating
</span>
) : null}
</div>
</button>
);
}

View file

@ -0,0 +1,362 @@
// 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 { createCodePlugin } from "@/components/assistant-ui/code-plugin";
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
import {
unslothDarkTheme,
unslothLightTheme,
} from "@/components/assistant-ui/code-themes";
import { Button } from "@/components/ui/button";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
import {
CheckIcon,
CopyIcon,
DownloadIcon,
EyeIcon,
Maximize2Icon,
XIcon,
} from "lucide-react";
import {
type KeyboardEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Streamdown } from "streamdown";
import { ArtifactHtmlFrame, type ArtifactViewMode } from "./html-frame";
import type { ChatArtifact } from "./types";
import { getArtifactFilename } from "./types";
const COPY_RESET_MS = 2000;
const artifactSourceCodePlugin = createCodePlugin({
themes: [unslothLightTheme, unslothDarkTheme],
});
function buildHtmlFence(source: string): string {
const longestBacktickRun = Math.max(
2,
...(source.match(/`+/g) ?? []).map((match) => match.length),
);
const fence = "`".repeat(longestBacktickRun + 1);
return `${fence}html\n${source}\n${fence}`;
}
// Sandboxed artifact iframes are intentionally excluded from the overlay focus
// trap. Granting same-origin sandbox privileges would weaken isolation, so
// keyboard users can reach Studio controls here while fully interactive artifact
// content remains a known sandbox limitation.
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
function getFocusableElements(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter(
(element) =>
!element.hasAttribute("disabled") &&
element.getAttribute("aria-hidden") !== "true" &&
element.tabIndex !== -1,
);
}
function ArtifactLoadingLine() {
return (
<div className="absolute inset-x-0 bottom-0 h-[2.5px] overflow-hidden bg-border/45">
<span
aria-hidden={true}
className="artifact-loading-line block h-full rounded-full motion-reduce:hidden"
/>
</div>
);
}
function ArtifactGeneratingPanel() {
return (
<div className="flex h-full min-h-0 flex-col items-center justify-center bg-muted/10 px-6 text-center">
<div className="max-w-[30ch] space-y-1.5">
<img
src="/Sloth%20emojis/sloth%20w%20pc%20transparent.png"
alt=""
aria-hidden={true}
className="mx-auto mb-3 size-20 object-contain"
/>
<p className="text-sm font-medium text-foreground">
Building artifact preview
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
The preview will appear here when the HTML is ready.
</p>
</div>
</div>
);
}
function downloadTextFile(filename: string, text: string): void {
const blob = new Blob([text], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
export function ArtifactSurface({
artifact,
variant,
onClose,
onOpenFullscreen,
}: {
artifact: ChatArtifact;
variant: "panel" | "overlay";
onClose: () => void;
onOpenFullscreen?: () => void;
}) {
const [viewMode, setViewMode] = useState<ArtifactViewMode>("preview");
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const surfaceRef = useRef<HTMLElement>(null);
const previousFocusRef = useRef<Element | null>(null);
const filename = getArtifactFilename(artifact);
const sourceMarkdown = useMemo(
() => buildHtmlFence(artifact.code),
[artifact.code],
);
const hasArtifactCode = artifact.code.trim().length > 0;
const isLoadingArtifact = Boolean(artifact.isStreaming);
const effectiveViewMode = isLoadingArtifact ? "preview" : viewMode;
useEffect(() => {
return () => {
if (copyResetRef.current) clearTimeout(copyResetRef.current);
};
}, []);
useEffect(() => {
if (variant !== "overlay") return;
previousFocusRef.current = document.activeElement;
const timeoutId = window.setTimeout(() => {
const surface = surfaceRef.current;
if (!surface) return;
const firstFocusable = getFocusableElements(surface)[0];
if (firstFocusable) {
firstFocusable.focus();
} else {
surface.focus();
}
}, 0);
return () => {
window.clearTimeout(timeoutId);
const previousFocus = previousFocusRef.current;
if (previousFocus instanceof HTMLElement) previousFocus.focus();
};
}, [variant]);
const handleCopy = async () => {
if (!(await copyToClipboard(artifact.code))) return;
setCopied(true);
if (copyResetRef.current) clearTimeout(copyResetRef.current);
copyResetRef.current = setTimeout(() => {
setCopied(false);
copyResetRef.current = null;
}, COPY_RESET_MS);
};
const handleDialogKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (variant !== "overlay") return;
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab") return;
const focusable = getFocusableElements(event.currentTarget);
if (focusable.length === 0) {
event.preventDefault();
event.currentTarget.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
const content = (
<section
ref={surfaceRef}
role={variant === "overlay" ? "dialog" : undefined}
aria-modal={variant === "overlay" ? true : undefined}
tabIndex={variant === "overlay" ? -1 : undefined}
onKeyDown={handleDialogKeyDown}
className={cn(
"relative flex min-h-0 flex-col border border-border bg-background",
variant === "panel"
? "artifact-panel-shell mx-2 mt-[72px] mb-8 h-[calc(100%_-_104px)] overflow-visible rounded-[28px] border-border/70 bg-card/95 [box-shadow:rgba(0,0,0,0.16)_0px_2px_8px_-2px]"
: "h-[min(92vh,900px)] w-[min(96vw,1200px)] overflow-hidden rounded-2xl shadow-xl",
)}
aria-label={`${artifact.title} artifact`}
>
<header
className={cn(
"relative flex shrink-0 items-center justify-between gap-3 px-2.5 py-2",
variant === "panel" && "rounded-t-[28px]",
)}
>
<div
className="flex items-center gap-1 rounded-full bg-muted/40 p-0.5"
role="tablist"
aria-label="Artifact view"
>
{(["preview", "source"] as const).map((mode) => {
const isPreview = mode === "preview";
const Icon = isPreview ? EyeIcon : CodeToggleIcon;
return (
<button
key={mode}
type="button"
role="tab"
disabled={isLoadingArtifact && !isPreview}
onClick={() => setViewMode(mode)}
className={cn(
"flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors",
effectiveViewMode === mode
? "bg-background text-foreground shadow-sm"
: "hover:bg-background/70 hover:text-foreground",
isLoadingArtifact &&
!isPreview &&
"cursor-not-allowed opacity-50",
)}
aria-label={
isPreview ? "Preview artifact" : "View artifact source"
}
aria-selected={effectiveViewMode === mode}
aria-pressed={effectiveViewMode === mode}
title={
isPreview
? "Preview"
: isLoadingArtifact
? "Source available when generation finishes"
: "Source"
}
>
<Icon className="size-4" />
</button>
);
})}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
disabled={isLoadingArtifact || !hasArtifactCode}
onClick={() => downloadTextFile(filename, artifact.code)}
aria-label="Download artifact HTML"
>
<DownloadIcon className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
disabled={isLoadingArtifact || !hasArtifactCode}
onClick={handleCopy}
aria-label="Copy artifact HTML"
>
{copied ? (
<CheckIcon className="size-4" />
) : (
<CopyIcon className="size-4" />
)}
</Button>
{variant === "panel" && onOpenFullscreen ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={onOpenFullscreen}
aria-label="Open artifact fullscreen"
>
<Maximize2Icon className="size-4" />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={onClose}
aria-label="Close artifact"
>
<XIcon className="size-4" />
</Button>
</div>
{isLoadingArtifact ? (
<ArtifactLoadingLine />
) : (
<div className="absolute inset-x-0 bottom-0 h-px bg-border" />
)}
</header>
<div
className={cn(
"min-h-0 flex-1 overflow-hidden bg-background",
variant === "panel" && "rounded-b-[28px]",
)}
>
{isLoadingArtifact ? (
<ArtifactGeneratingPanel />
) : effectiveViewMode === "preview" ? (
<ArtifactHtmlFrame
key={artifact.id}
code={artifact.code}
title={artifact.title}
fill={true}
className="h-full"
/>
) : (
<div className="h-full overflow-auto text-xs leading-relaxed [&_[data-streamdown=code-block]]:!rounded-none [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:text-xs [&_pre]:leading-relaxed [&_code]:text-xs">
<Streamdown
mode="streaming"
plugins={{ code: artifactSourceCodePlugin }}
controls={{ code: false }}
shikiTheme={[unslothLightTheme, unslothDarkTheme]}
>
{sourceMarkdown}
</Streamdown>
</div>
)}
</div>
</section>
);
if (variant === "overlay") {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-4 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
{content}
</div>
);
}
return content;
}

View file

@ -0,0 +1,100 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import { getAuthToken } from "@/features/auth";
import { apiUrl } from "@/lib/api-base";
import { cn } from "@/lib/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import { hashArtifactCode } from "./types";
const HTML_FRAME_DEFAULT_HEIGHT = 400;
const HTML_FRAME_MAX_HEIGHT = 900;
export type ArtifactViewMode = "preview" | "source";
export const ARTIFACT_VIEW_MODES: readonly ArtifactViewMode[] = [
"preview",
"source",
];
export function isArtifactViewMode(value: string): value is ArtifactViewMode {
return (ARTIFACT_VIEW_MODES as readonly string[]).includes(value);
}
export function buildArtifactSrcDoc(code: string): string {
const resizeScript = `<script>(()=>{const post=()=>parent.postMessage({chatArtifactHeight:document.documentElement.scrollHeight},"*");new ResizeObserver(post).observe(document.documentElement);window.addEventListener("load",post);post();})();</script>`;
return `${code}\n${resizeScript}`;
}
// Preview iframes intentionally omit allow-downloads: generated artifacts can
// offer their own UI, but downloads must go through Studio's explicit
// copy/download controls outside the no-same-origin sandbox.
export function ArtifactHtmlFrame({
code,
title = "HTML artifact preview",
className,
fill = false,
}: {
code: string;
title?: string;
className?: string;
fill?: boolean;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const allowNetworkAccess = useChatRuntimeStore(
(state) => state.allowArtifactNetworkAccess,
);
const [height, setHeight] = useState(HTML_FRAME_DEFAULT_HEIGHT);
const artifactHtml = useMemo(() => buildArtifactSrcDoc(code), [code]);
const src = useMemo(() => {
const query = new URLSearchParams({ v: hashArtifactCode(code) });
if (allowNetworkAccess) {
const token = getAuthToken();
if (token) {
query.set("allow_network", "1");
query.set("token", token);
}
}
return apiUrl(`/api/inference/artifact-preview-frame?${query.toString()}`);
}, [allowNetworkAccess, code]);
const postArtifactHtml = useCallback(() => {
// The sandboxed frame intentionally has an opaque origin ("null").
// A wildcard target is required here;
// the payload is sent only to this iframe's contentWindow.
iframeRef.current?.contentWindow?.postMessage(
{ type: "unsloth:artifact-html", html: artifactHtml },
"*",
);
}, [artifactHtml]);
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.source !== iframeRef.current?.contentWindow) return;
if (event.origin !== "null") return;
if (typeof event.data?.chatArtifactHeight !== "number") return;
setHeight(
Math.min(
Math.max(event.data.chatArtifactHeight, 160),
HTML_FRAME_MAX_HEIGHT,
),
);
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, [postArtifactHtml]);
return (
<iframe
ref={iframeRef}
src={src}
sandbox="allow-scripts"
referrerPolicy="no-referrer"
onLoad={postArtifactHtml}
className={cn("block w-full border-0 bg-background", className)}
style={{ height: fill ? "100%" : height }}
title={title}
/>
);
}

View file

@ -0,0 +1,107 @@
// 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 { create } from "zustand";
import type { ChatArtifact, ChatArtifactSurface } from "./types";
const autoOpenedArtifactIds = new Set<string>();
export function hasAutoOpenedArtifact(artifactId: string): boolean {
return autoOpenedArtifactIds.has(artifactId);
}
export function rememberAutoOpenedArtifact(artifactId: string): void {
autoOpenedArtifactIds.add(artifactId);
}
export function clearAutoOpenedArtifacts(): void {
autoOpenedArtifactIds.clear();
}
type ChatArtifactsState = {
artifactsById: Record<string, ChatArtifact>;
selectedArtifactId: string | null;
surface: ChatArtifactSurface;
openArtifact: (
artifact: ChatArtifact,
options?: { surface?: ChatArtifactSurface },
) => void;
updateArtifact: (artifact: ChatArtifact) => void;
closeArtifactSurface: () => void;
clearArtifactsForThread: (threadId: string | null | undefined) => void;
clearOrphanedArtifacts: () => void;
resetArtifacts: () => void;
};
export const useChatArtifactsStore = create<ChatArtifactsState>((set) => ({
artifactsById: {},
selectedArtifactId: null,
surface: "panel",
openArtifact: (artifact, options) =>
set((state) => ({
artifactsById: {
...state.artifactsById,
[artifact.id]: artifact,
},
selectedArtifactId: artifact.id,
surface: options?.surface ?? state.surface,
})),
updateArtifact: (artifact) =>
set((state) =>
state.artifactsById[artifact.id]
? {
artifactsById: {
...state.artifactsById,
[artifact.id]: artifact,
},
}
: state,
),
closeArtifactSurface: () =>
set({ selectedArtifactId: null, surface: "panel" }),
clearArtifactsForThread: (threadId) =>
set((state) => {
if (!threadId) return state;
const artifactsById = Object.fromEntries(
Object.entries(state.artifactsById).filter(
([, artifact]) => artifact.threadId !== threadId,
),
);
const selected = state.selectedArtifactId
? artifactsById[state.selectedArtifactId]
: null;
return {
artifactsById,
selectedArtifactId: selected ? selected.id : null,
};
}),
clearOrphanedArtifacts: () =>
set((state) => {
const artifactsById = Object.fromEntries(
Object.entries(state.artifactsById).filter(
([, artifact]) => artifact.threadId != null,
),
);
const selected = state.selectedArtifactId
? artifactsById[state.selectedArtifactId]
: null;
return {
artifactsById,
selectedArtifactId: selected ? selected.id : null,
};
}),
resetArtifacts: () =>
set({
artifactsById: {},
selectedArtifactId: null,
surface: "panel",
}),
}));
export function useSelectedChatArtifact(): ChatArtifact | null {
return useChatArtifactsStore((state) =>
state.selectedArtifactId
? (state.artifactsById[state.selectedArtifactId] ?? null)
: null,
);
}

View file

@ -0,0 +1,84 @@
// 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 type ChatArtifactSource = "tool" | "fence";
export type ChatArtifactSurface = "panel" | "overlay";
export interface ChatArtifact {
id: string;
title: string;
code: string;
source: ChatArtifactSource;
sourceMessageId?: string | null;
sourceToolCallId?: string | null;
threadId?: string | null;
isStreaming?: boolean;
createdAt: number;
}
export interface ChatArtifactInput {
title?: string | null;
code: string;
source: ChatArtifactSource;
sourceMessageId?: string | null;
sourceToolCallId?: string | null;
threadId?: string | null;
isStreaming?: boolean;
}
const DEFAULT_ARTIFACT_TITLE = "HTML artifact";
export function normalizeArtifactTitle(title?: string | null): string {
const trimmed = title?.trim();
return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_ARTIFACT_TITLE;
}
export function hashArtifactCode(code: string): string {
let hash = 5381;
for (let i = 0; i < code.length; i += 1) {
hash = ((hash << 5) + hash) ^ code.charCodeAt(i);
}
return (hash >>> 0).toString(36);
}
export function createArtifactId(input: ChatArtifactInput): string {
const threadSegment = input.threadId || "no-thread";
const messageSegment = input.sourceMessageId || "transient";
// Backend tool call IDs (call_0, call_1, …) reset per request, so
// the message ID is needed to scope them to a specific turn.
const parts = [input.source, threadSegment, messageSegment];
if (input.source === "tool" && input.sourceToolCallId) {
parts.push(input.sourceToolCallId);
} else {
parts.push(hashArtifactCode(input.code));
}
return parts.join(":");
}
export function createChatArtifact(input: ChatArtifactInput): ChatArtifact {
return {
id: createArtifactId(input),
title: normalizeArtifactTitle(input.title),
code: input.code,
source: input.source,
sourceMessageId: input.sourceMessageId ?? null,
sourceToolCallId: input.sourceToolCallId ?? null,
threadId: input.threadId ?? null,
isStreaming: input.isStreaming,
createdAt: Date.now(),
};
}
export function getArtifactFilename(
artifact: Pick<ChatArtifact, "title">,
): string {
const slug = artifact.title
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
return `${slug || "artifact"}.html`;
}

View file

@ -66,23 +66,40 @@ function headersToObject(rows: HeaderRow[]): Record<string, string> | undefined
return Object.keys(out).length > 0 ? out : undefined;
}
function isValidUrl(url: string): boolean {
const trimmed = url.trim();
// A non-HTTP address is a local stdio command. Case-insensitive to match the
// backend's is_stdio(), so all layers split http-vs-command identically.
function isHttpAddress(value: string): boolean {
const trimmed = value.trim().toLowerCase();
return trimmed.startsWith("http://") || trimmed.startsWith("https://");
}
function isValidAddress(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
try {
const parsed = new URL(trimmed);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
if (isHttpAddress(trimmed)) {
try {
const parsed = new URL(trimmed);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
// Anything else is treated as a local command (stdio); the backend gates
// whether stdio servers are allowed on this host. Reject other URL schemes
// only when the command itself is a URL; "://" is fine inside an argument
// (e.g. a database connection string passed to the server).
return !trimmed.split(/\s+/)[0].includes("://");
}
function HeadersEditor({
rows,
onChange,
stdio,
}: {
rows: HeaderRow[];
onChange: (rows: HeaderRow[]) => void;
// stdio servers reuse this editor for environment variables instead of headers.
stdio: boolean;
}) {
const update = (id: string, patch: Partial<HeaderRow>) =>
onChange(rows.map((row) => (row.id === id ? { ...row, ...patch } : row)));
@ -91,19 +108,41 @@ function HeadersEditor({
const remove = (id: string) =>
onChange(rows.filter((row) => row.id !== id));
const copy = stdio
? {
label: "Environment variables",
add: "Add variable",
keyPlaceholder: "Variable name",
valuePlaceholder: "Variable value",
remove: "Remove variable",
}
: {
label: "Custom headers",
add: "Add header",
keyPlaceholder: "Header name",
valuePlaceholder: "Header value",
remove: "Remove header",
};
return (
<>
<div className="flex items-center justify-between">
<Label className="text-sm">Custom headers</Label>
<Label className="text-sm">{copy.label}</Label>
<Button type="button" variant="ghost" size="sm" onClick={add}>
<HugeiconsIcon icon={PlusSignIcon} size={14} />
Add header
{copy.add}
</Button>
</div>
{rows.length === 0 ? (
<div className="text-xs text-muted-foreground">
Optional. Add an <code>Authorization</code> header here for servers
that require auth.
{stdio ? (
"Optional. Environment variables passed to the server process."
) : (
<>
Optional. Add an <code>Authorization</code> header here for servers
that require auth.
</>
)}
</div>
) : (
<div className="flex flex-col gap-2">
@ -111,12 +150,12 @@ function HeadersEditor({
<div key={row.id} className="flex items-center gap-2">
<Input
value={row.key}
placeholder="Header name"
placeholder={copy.keyPlaceholder}
onChange={(e) => update(row.id, { key: e.target.value })}
/>
<Input
value={row.value}
placeholder="Header value"
placeholder={copy.valuePlaceholder}
onChange={(e) => update(row.id, { value: e.target.value })}
/>
<Button
@ -124,7 +163,7 @@ function HeadersEditor({
variant="ghost"
size="icon"
onClick={() => remove(row.id)}
aria-label="Remove header"
aria-label={copy.remove}
>
<HugeiconsIcon icon={Delete02Icon} size={14} />
</Button>
@ -199,8 +238,8 @@ export function ChatMcpServersDialog({
async function testConnection() {
const trimmedUrl = form.url.trim();
if (!isValidUrl(trimmedUrl)) {
toast.error("Enter a valid http:// or https:// URL first");
if (!isValidAddress(trimmedUrl)) {
toast.error("Enter an http(s):// URL or a local command first");
return;
}
setTesting(true);
@ -236,11 +275,11 @@ export function ChatMcpServersDialog({
return;
}
if (!trimmedUrl) {
toast.error("URL is required");
toast.error("URL or command is required");
return;
}
if (!isValidUrl(trimmedUrl)) {
toast.error("URL must start with http:// or https://");
if (!isValidAddress(trimmedUrl)) {
toast.error("Enter an http(s):// URL or a local command");
return;
}
setSaving(true);
@ -331,6 +370,9 @@ export function ChatMcpServersDialog({
}
const showForm = view.kind !== "list";
// A local stdio command uses env vars, not headers or OAuth.
const addressIsCommand =
form.url.trim() !== "" && !isHttpAddress(form.url);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -338,7 +380,7 @@ export function ChatMcpServersDialog({
<DialogHeader>
<DialogTitle>MCP Servers</DialogTitle>
<DialogDescription>
Register remote MCP servers.
Register remote (HTTP) or local (stdio command) MCP servers.
</DialogDescription>
</DialogHeader>
@ -356,40 +398,47 @@ export function ChatMcpServersDialog({
/>
</div>
<div className="grid gap-2">
<Label htmlFor="mcp-url">URL</Label>
<Label htmlFor="mcp-url">URL or command</Label>
<Input
id="mcp-url"
value={form.url}
onChange={(e) =>
setForm((prev) => ({ ...prev, url: e.target.value }))
}
placeholder="https://example.com/mcp"
placeholder="https://example.com/mcp or npx -y @modelcontextprotocol/server-filesystem /tmp"
/>
<span className="text-xs text-muted-foreground">
An http(s) URL for a remote server, or a local command to run an
stdio server (desktop app only).
</span>
</div>
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-0.5">
<Label className="text-sm" htmlFor="mcp-oauth">
Use OAuth sign-in
</Label>
<span className="text-xs text-muted-foreground">
For servers that require browser-based authentication
(GitHub, Linear, etc.). A browser window will open on first
connect.
</span>
{!addressIsCommand && (
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-0.5">
<Label className="text-sm" htmlFor="mcp-oauth">
Use OAuth sign-in
</Label>
<span className="text-xs text-muted-foreground">
For servers that require browser-based authentication
(GitHub, Linear, etc.). A browser window will open on first
connect.
</span>
</div>
<Switch
id="mcp-oauth"
checked={form.useOauth}
onCheckedChange={(useOauth) =>
setForm((prev) => ({ ...prev, useOauth }))
}
/>
</div>
<Switch
id="mcp-oauth"
checked={form.useOauth}
onCheckedChange={(useOauth) =>
setForm((prev) => ({ ...prev, useOauth }))
}
/>
</div>
)}
<HeadersEditor
rows={form.headers}
onChange={(headers) => setForm((prev) => ({ ...prev, headers }))}
stdio={addressIsCommand}
/>
<div className="flex items-center justify-between gap-2 pt-2">
@ -415,7 +464,7 @@ export function ChatMcpServersDialog({
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex min-w-0 flex-col gap-3">
<div className="flex justify-end">
<Button size="sm" onClick={startCreate}>
<HugeiconsIcon icon={PlusSignIcon} size={14} />

View file

@ -10,15 +10,22 @@ import {
ModelSelector,
} from "@/components/assistant-ui/model-selector";
import { Thread } from "@/components/assistant-ui/thread";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { useSidebar } from "@/components/ui/sidebar";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { NativeModelChip } from "@/features/native-intents/components/native-model-chip";
import { NativeModelDropOverlay } from "@/features/native-intents/components/native-model-drop-overlay";
import { useNativeIntentStore } from "@/features/native-intents/store";
import type { NativeIntent } from "@/features/native-intents/types";
import { useChooseNativeModel } from "@/features/native-intents/use-native-dialogs";
import { useNativeModelDrop } from "@/features/native-intents/use-native-drop";
import { useNativePathLeasesSupported } from "@/features/native-intents/use-native-readiness";
import {
NativeModelChip,
NativeModelDropOverlay,
type NativeIntent,
useChooseNativeModel,
useNativeIntentStore,
useNativeModelDrop,
useNativePathLeasesSupported,
} from "@/features/native-intents";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils";
@ -26,6 +33,7 @@ import { CustomizeIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import type { PanelImperativeHandle } from "react-resizable-panels";
import {
type ReactElement,
memo,
@ -78,6 +86,13 @@ import {
} from "./stores/chat-runtime-store";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import { buildChatTourSteps } from "./tour";
import { ArtifactSurface } from "./artifacts/artifact-surface";
import {
clearAutoOpenedArtifacts,
useChatArtifactsStore,
useSelectedChatArtifact,
} from "./artifacts/store";
import type { ChatArtifact, ChatArtifactSurface } from "./artifacts/types";
import type { ChatView, MessageRecord } from "./types";
import {
getStoredChatThread,
@ -134,6 +149,12 @@ function pickBestLoraForBase(
return partial ?? sorted[0] ?? null;
}
function isAssistantLocalThreadId(
threadId: string | null | undefined,
): boolean {
return Boolean(threadId?.startsWith("__LOCALID_"));
}
function messageHasImage(message: MessageRecord): boolean {
const contentParts = Array.isArray(message.content) ? message.content : [];
if (contentParts.some((part) => part.type === "image")) {
@ -153,19 +174,161 @@ function messageHasImage(message: MessageRecord): boolean {
return false;
}
const ARTIFACT_PANEL_DEFAULT_SIZE = "38%";
const ARTIFACT_PANEL_TRANSITION_MS = 260;
const ARTIFACT_SURFACE_POP_DELAY_MS = 150;
const SingleContent = memo(function SingleContent({
threadId,
newThreadNonce,
}: { threadId?: string; newThreadNonce?: string }): ReactElement {
artifact,
artifactSurface,
onCloseArtifact,
}: {
threadId?: string;
newThreadNonce?: string;
artifact?: ChatArtifact | null;
artifactSurface: ChatArtifactSurface;
onCloseArtifact: () => void;
}): ReactElement {
const openArtifact = useChatArtifactsStore((state) => state.openArtifact);
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
const artifactPanelRef = useRef<PanelImperativeHandle | null>(null);
const hasInitializedArtifactPanelRef = useRef(false);
const [isArtifactLayoutAnimating, setIsArtifactLayoutAnimating] =
useState(false);
const [isArtifactPanelLayoutActive, setIsArtifactPanelLayoutActive] =
useState(false);
const [isArtifactSurfaceVisible, setIsArtifactSurfaceVisible] =
useState(false);
const showArtifactPanel = Boolean(
artifact &&
artifactSurface === "panel" &&
(threadId
? !artifact.threadId || artifact.threadId === threadId
: Boolean(newThreadNonce) ||
Boolean(artifact.threadId && artifact.threadId === activeThreadId)),
);
const artifactLayoutActive = showArtifactPanel || isArtifactPanelLayoutActive;
const artifactPanelSettledOpen =
showArtifactPanel &&
isArtifactPanelLayoutActive &&
!isArtifactLayoutAnimating;
useEffect(() => {
const panel = artifactPanelRef.current;
if (!panel) return;
setIsArtifactSurfaceVisible(false);
if (!hasInitializedArtifactPanelRef.current) {
hasInitializedArtifactPanelRef.current = true;
if (!showArtifactPanel) {
panel.resize("0%");
return;
}
}
setIsArtifactPanelLayoutActive(true);
setIsArtifactLayoutAnimating(true);
let resizeFrameId = 0;
const prepFrameId = window.requestAnimationFrame(() => {
resizeFrameId = window.requestAnimationFrame(() => {
panel.resize(showArtifactPanel ? ARTIFACT_PANEL_DEFAULT_SIZE : "0%");
});
});
const surfaceTimerId = showArtifactPanel
? window.setTimeout(() => {
setIsArtifactSurfaceVisible(true);
}, ARTIFACT_SURFACE_POP_DELAY_MS)
: 0;
const timeoutId = window.setTimeout(() => {
setIsArtifactLayoutAnimating(false);
if (!showArtifactPanel) {
setIsArtifactPanelLayoutActive(false);
}
}, ARTIFACT_PANEL_TRANSITION_MS + 60);
return () => {
window.cancelAnimationFrame(prepFrameId);
if (resizeFrameId) {
window.cancelAnimationFrame(resizeFrameId);
}
if (surfaceTimerId) {
window.clearTimeout(surfaceTimerId);
}
window.clearTimeout(timeoutId);
};
}, [showArtifactPanel]);
const threadPane = (
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread hideWelcome={Boolean(threadId)} targetThreadId={threadId} />
</div>
);
return (
<ChatRuntimeProvider
modelType="base"
initialThreadId={threadId}
newThreadNonce={newThreadNonce}
>
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread hideWelcome={Boolean(threadId)} targetThreadId={threadId} />
</div>
<ResizablePanelGroup
orientation="horizontal"
data-artifact-layout-animating={
isArtifactLayoutAnimating ? "true" : "false"
}
className="chat-artifact-split min-h-0 min-w-0 flex-1 basis-0 overflow-hidden"
>
<ResizablePanel
id="chat-thread"
defaultSize="100%"
minSize={artifactLayoutActive ? "42%" : "100%"}
className="h-full min-h-0 min-w-0 overflow-hidden"
>
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
{threadPane}
</div>
</ResizablePanel>
<ResizableHandle
withHandle={false}
className={cn(
"relative z-30 -ml-1 -mr-4 w-5 bg-transparent transition-[width,margin] duration-[260ms] ease-[var(--ease-out-cubic)] hover:bg-transparent hover:shadow-none active:bg-transparent active:shadow-none focus-visible:bg-transparent focus-visible:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none",
!artifactLayoutActive && "pointer-events-none -ml-0 -mr-0 w-0",
)}
/>
<ResizablePanel
panelRef={artifactPanelRef}
id="chat-artifact"
defaultSize="0%"
minSize={artifactPanelSettledOpen ? "30%" : "0%"}
maxSize={artifactLayoutActive ? "58%" : "0%"}
collapsible={true}
collapsedSize="0%"
className={cn(
"h-full min-h-0 min-w-0 overflow-visible",
!showArtifactPanel && "pointer-events-none",
)}
>
<div
data-artifact-surface-visible={
isArtifactSurfaceVisible ? "true" : "false"
}
className="chat-artifact-pop-surface flex h-full min-h-0 min-w-0 flex-col overflow-visible"
>
{showArtifactPanel && artifact ? (
<ArtifactSurface
artifact={artifact}
variant="panel"
onClose={onCloseArtifact}
onOpenFullscreen={() =>
openArtifact(artifact, { surface: "overlay" })
}
/>
) : null}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ChatRuntimeProvider>
);
});
@ -331,15 +494,17 @@ const LoraCompareContent = memo(function LoraCompareContent({
useEffect(() => {
let isActive = true;
listStoredChatThreads({ pairId }).then((threads) => {
if (!isActive) return;
setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
}).catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
listStoredChatThreads({ pairId })
.then((threads) => {
if (!isActive) return;
setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
})
.catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
return () => {
isActive = false;
};
@ -480,21 +645,25 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
useEffect(() => {
let isActive = true;
listStoredChatThreads({ pairId }).then((threads) => {
if (!isActive) return;
setModel1ThreadId(
threads.find((t) => t.modelType === "model1" || t.modelType === "base")
?.id,
);
setModel2ThreadId(
threads.find((t) => t.modelType === "model2" || t.modelType === "lora")
?.id,
);
}).catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
listStoredChatThreads({ pairId })
.then((threads) => {
if (!isActive) return;
setModel1ThreadId(
threads.find(
(t) => t.modelType === "model1" || t.modelType === "base",
)?.id,
);
setModel2ThreadId(
threads.find(
(t) => t.modelType === "model2" || t.modelType === "lora",
)?.id,
);
})
.catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
return () => {
isActive = false;
};
@ -632,7 +801,11 @@ export function ChatPage(): ReactElement {
const modelsError = useChatRuntimeStore((state) => state.modelsError);
const modelLoading = useChatRuntimeStore((state) => state.modelLoading);
const clearCheckpoint = useChatRuntimeStore((state) => state.clearCheckpoint);
const resetArtifacts = useChatArtifactsStore((state) => state.resetArtifacts);
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
const persistedActiveThreadId = isAssistantLocalThreadId(activeThreadId)
? null
: activeThreadId;
const modelOperationInProgress = useChatRuntimeStore(
(state) => state.modelLoading,
);
@ -647,9 +820,9 @@ export function ChatPage(): ReactElement {
} = useChatModelRuntime();
const prevConnectionsEnabledRef = useRef(connectionsEnabled);
useEffect(() => {
const turnedOff =
prevConnectionsEnabledRef.current && !connectionsEnabled;
const turnedOff = prevConnectionsEnabledRef.current && !connectionsEnabled;
if (!connectionsEnabled && isExternalModelId(inferenceParams.checkpoint)) {
resetArtifacts();
clearCheckpoint();
if (turnedOff) {
toast.info("Connections disabled", {
@ -662,6 +835,7 @@ export function ChatPage(): ReactElement {
clearCheckpoint,
connectionsEnabled,
inferenceParams.checkpoint,
resetArtifacts,
]);
const pendingNativeModelIntent = useNativeIntentStore(
(state) => state.pendingModelIntent,
@ -681,17 +855,19 @@ export function ChatPage(): ReactElement {
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 supportsReasoningOff = useChatRuntimeStore(
(s) => s.supportsReasoningOff,
);
const activeExternalProvider = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null;
return (
externalProvidersForChat.find(
(p) => p.id === selection.providerId,
) ?? null
externalProvidersForChat.find((p) => p.id === selection.providerId) ??
null
);
}, [externalProvidersForChat, inferenceParams.checkpoint]);
const activeExternalProviderType = activeExternalProvider?.providerType ?? null;
const activeExternalProviderType =
activeExternalProvider?.providerType ?? null;
const activeProviderCapabilities = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null;
@ -807,7 +983,9 @@ export function ChatPage(): ReactElement {
(provider?.providerType === "anthropic" ||
provider?.providerType === "openai");
const storedToolsEnabled = loadOptionalBool(CHAT_TOOLS_ENABLED_KEY);
const storedCodeToolsEnabled = loadOptionalBool(CHAT_CODE_TOOLS_ENABLED_KEY);
const storedCodeToolsEnabled = loadOptionalBool(
CHAT_CODE_TOOLS_ENABLED_KEY,
);
const storedImageToolsEnabled = loadOptionalBool(
CHAT_IMAGE_TOOLS_ENABLED_KEY,
);
@ -876,14 +1054,43 @@ export function ChatPage(): ReactElement {
if (search.thread) {
return { mode: "single", threadId: search.thread };
}
if (activeThreadId && !activeThreadId.startsWith("__LOCALID_")) {
return { mode: "single", threadId: activeThreadId };
if (persistedActiveThreadId) {
return { mode: "single", threadId: persistedActiveThreadId };
}
if (search.new) {
return { mode: "single", newThreadNonce: search.new };
}
return { mode: "single" };
}, [search.thread, search.compare, search.new, activeThreadId]);
}, [search.thread, search.compare, search.new, persistedActiveThreadId]);
const selectedArtifact = useSelectedChatArtifact();
const artifactSurface = useChatArtifactsStore((state) => state.surface);
const closeArtifactSurface = useChatArtifactsStore(
(state) => state.closeArtifactSurface,
);
const artifactViewKey =
view.mode === "single"
? `single:${view.threadId ?? view.newThreadNonce ?? "new"}`
: `compare:${view.pairId}`;
useEffect(() => {
clearAutoOpenedArtifacts();
closeArtifactSurface();
}, [artifactViewKey, closeArtifactSurface]);
useEffect(() => {
if (view.mode !== "single") return;
if (view.threadId || view.newThreadNonce || !selectedArtifact) return;
// view intentionally excludes __LOCALID_ threads (they fall through to
// { mode: "single" } with no threadId/nonce). Don't close an artifact
// whose thread is the currently active local thread.
if (
selectedArtifact.threadId &&
selectedArtifact.threadId === activeThreadId
)
return;
closeArtifactSurface();
}, [activeThreadId, closeArtifactSurface, selectedArtifact, view]);
const hasActiveModel = Boolean(inferenceParams.checkpoint);
const loadNativeModelIntent = useCallback(
@ -1018,11 +1225,12 @@ export function ChatPage(): ReactElement {
selectedExternal?.modelId,
selectedProvider?.baseUrl,
);
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
selectedProvider?.providerType,
selectedExternal?.modelId,
selectedProvider?.baseUrl,
);
const supportsBuiltinCodeExecution =
providerSupportsBuiltinCodeExecution(
selectedProvider?.providerType,
selectedExternal?.modelId,
selectedProvider?.baseUrl,
);
const supportsBuiltinImageGeneration =
providerSupportsBuiltinImageGeneration(
selectedProvider?.providerType,
@ -1153,8 +1361,12 @@ export function ChatPage(): ReactElement {
],
);
const handleEject = useCallback(() => {
void ejectModel();
}, [ejectModel]);
void (async () => {
if (await ejectModel()) {
resetArtifacts();
}
})();
}, [ejectModel, resetArtifacts]);
const openModelSelector = useCallback(() => {
setModelSelectorLocked(true);
@ -1438,6 +1650,7 @@ export function ChatPage(): ReactElement {
const tourSteps = useMemo(
() =>
// eslint-disable-next-line react-hooks/refs -- buildChatTourSteps stores callbacks without invoking them during render.
buildChatTourSteps({
canCompare,
openModelSelector,
@ -1475,6 +1688,11 @@ export function ChatPage(): ReactElement {
return () => window.clearTimeout(timeoutId);
}, [modelSelectorLocked, tour.open]);
const showArtifactOverlay = Boolean(
selectedArtifact &&
(view.mode === "compare" || artifactSurface === "overlay"),
);
return (
<div className="flex min-h-0 min-w-0 flex-1 basis-0 bg-background overflow-hidden">
<GuidedTour {...tour.tourProps} />
@ -1593,9 +1811,12 @@ export function ChatPage(): ReactElement {
{view.mode === "single" ? (
<SingleContent
key={view.threadId ?? "single"}
key={view.threadId ?? view.newThreadNonce ?? "single"}
threadId={view.threadId}
newThreadNonce={view.newThreadNonce}
artifact={selectedArtifact}
artifactSurface={artifactSurface}
onCloseArtifact={closeArtifactSurface}
/>
) : (
<CompareContent
@ -1608,6 +1829,14 @@ export function ChatPage(): ReactElement {
deleteDisabled={modelOperationInProgress}
/>
)}
{showArtifactOverlay && selectedArtifact ? (
<ArtifactSurface
artifact={selectedArtifact}
variant="overlay"
onClose={closeArtifactSurface}
/>
) : null}
</div>
<ChatSettingsPanel

View file

@ -83,7 +83,6 @@ import {
toPresetParams,
} from "./presets/preset-policy";
import {
EXTERNAL_MAX_OUTPUT_TOKENS,
type ProviderCapabilities,
getExternalMaxOutputTokens,
getExternalMinOutputTokens,
@ -438,16 +437,6 @@ export function ChatSettingsPanel({
(s) => s.modelRequiresTrustRemoteCode,
);
const currentCheckpoint = params.checkpoint;
const currentModelIsMultimodal = useChatRuntimeStore((s) => {
if (s.loadedIsMultimodal) return true;
const m = s.models.find((m) => m.id === currentCheckpoint);
return (
Boolean(m?.isVision) ||
Boolean(m?.isAudio) ||
Boolean(m?.hasAudioInput) ||
m?.audioType === "audio_vlm"
);
});
const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
const ggufMaxContextLength = useChatRuntimeStore(
(s) => s.ggufMaxContextLength,

View file

@ -1118,15 +1118,15 @@ export function useChatModelRuntime() {
],
);
const ejectModel = useCallback(async () => {
const ejectModel = useCallback(async (): Promise<boolean> => {
if (!params.checkpoint) {
return;
return false;
}
setModelsError(null);
if (isExternalModelId(params.checkpoint)) {
clearCheckpoint();
await refresh();
return;
return true;
}
try {
async function performUnload(): Promise<void> {
@ -1135,17 +1135,21 @@ export function useChatModelRuntime() {
await refresh();
}
await toast.promise(performUnload(), {
const unloadPromise = performUnload();
toast.promise(unloadPromise, {
loading: "Unloading model",
success: { message: "Model unloaded", duration: 1200 },
error: (err) =>
err instanceof Error ? err.message : "Failed to unload model",
description: "Releases VRAM and resets inference state.",
});
await unloadPromise;
return true;
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to unload model";
setModelsError(message);
return false;
}
}, [clearCheckpoint, params.checkpoint, refresh, setModelsError]);

View file

@ -4,6 +4,7 @@
import { useEffect, useState } from "react";
import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import { useChatArtifactsStore } from "../artifacts/store";
import type { ThreadRecord } from "../types";
import {
deleteStoredChatThreads,
@ -157,6 +158,10 @@ export async function deleteChatItem(
// generating against a thread that no longer exists.
for (const id of threadIds) cancelIfRunning(id);
const artifactStore = useChatArtifactsStore.getState();
for (const id of threadIds) artifactStore.clearArtifactsForThread(id);
artifactStore.clearOrphanedArtifacts();
// Optimistic tombstone: hide immediately; roll back on backend error.
markChatThreadsDeleted(threadIds);
notifyChatHistoryUpdated();

View file

@ -22,6 +22,11 @@ export { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
export { ChatSearchDialog } from "./components/chat-search-dialog";
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";
export { clearAllChats, countAllChats } from "./utils/clear-all-chats";
export { ArtifactCard } from "./artifacts/artifact-card";
export {
useChatArtifactsStore,
useSelectedChatArtifact,
} from "./artifacts/store";
export { downloadChatExport } from "./utils/export-chat-history";
export {
deleteChatItem,

View file

@ -24,6 +24,7 @@ import { useAui } from "@assistant-ui/react";
import {
ArrowUpIcon,
DownloadIcon,
FileTextIcon,
GlobeIcon,
HeadphonesIcon,
LightbulbIcon,
@ -37,7 +38,10 @@ import { Image03Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { toast } from "@/lib/toast";
import { loadModel, validateModel } from "./api/chat-api";
import { parseExternalModelId, providerTypeSupportsVision } from "./external-providers";
import {
parseExternalModelId,
providerTypeSupportsVision,
} from "./external-providers";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import {
type ReasoningEffort,
@ -101,7 +105,10 @@ function fileToBase64DataURL(file: File): Promise<string> {
});
}
function formatReasoningEffortLabel(level: ReasoningEffort, modelId?: string): string {
function formatReasoningEffortLabel(
level: ReasoningEffort,
modelId?: string,
): string {
if (level === "max") return "Max";
if (level === "xhigh") {
const normalized = modelId?.trim().toLowerCase() ?? "";
@ -137,7 +144,12 @@ function useDictation(
const start = useCallback(() => {
const SpeechRecognitionAPI =
typeof window !== "undefined" &&
(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: typeof SpeechRecognition }).webkitSpeechRecognition);
(window.SpeechRecognition ??
(
window as unknown as {
webkitSpeechRecognition?: typeof SpeechRecognition;
}
).webkitSpeechRecognition);
if (!SpeechRecognitionAPI) {
return;
}
@ -183,7 +195,11 @@ function useDictation(
const supported =
typeof window !== "undefined" &&
!!(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition);
!!(
window.SpeechRecognition ??
(window as unknown as { webkitSpeechRecognition?: unknown })
.webkitSpeechRecognition
);
return { isDictating, start, stop, supported };
}
@ -222,9 +238,18 @@ export function RegisterCompareHandle({
currentHandles[name] = {
// fixes occasional reorder on reload.
append: (content) =>
aui.thread().append({ role: "user", content, createdAt: new Date() } as never),
aui
.thread()
.append({ role: "user", content, createdAt: new Date() } as never),
appendMessage: (content) =>
aui.thread().append({ role: "user", content, createdAt: new Date(), startRun: false } as never),
aui
.thread()
.append({
role: "user",
content,
createdAt: new Date(),
startRun: false,
} as never),
startRun: () => {
const msgs = aui.thread().getState().messages;
const lastId = msgs.length > 0 ? msgs[msgs.length - 1].id : null;
@ -268,7 +293,8 @@ function PendingImageThumb({
setSrc(url);
return () => URL.revokeObjectURL(url);
}, [file]);
if (!src) return <div className="size-14 animate-pulse rounded-[14px] bg-muted" />;
if (!src)
return <div className="size-14 animate-pulse rounded-[14px] bg-muted" />;
return (
<div className="relative size-14 shrink-0 overflow-hidden rounded-[14px] border border-foreground/20 bg-muted">
<img src={src} alt={file.name} className="h-full w-full object-cover" />
@ -303,7 +329,10 @@ export function SharedComposer({
const [running, setRunning] = useState(false);
const [comparing, setComparing] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [pendingAudio, setPendingAudio] = useState<{ name: string; base64: string } | null>(null);
const [pendingAudio, setPendingAudio] = useState<{
name: string;
base64: string;
} | null>(null);
const [dragging, setDragging] = useState(false);
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -332,10 +361,16 @@ 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 supportsReasoningOff = useChatRuntimeStore(
(s) => s.supportsReasoningOff,
);
const reasoningEffortLevels = useChatRuntimeStore(
(s) => s.reasoningEffortLevels,
);
const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort);
const supportsPreserveThinking = useChatRuntimeStore((s) => s.supportsPreserveThinking);
const supportsPreserveThinking = useChatRuntimeStore(
(s) => s.supportsPreserveThinking,
);
const preserveThinking = useChatRuntimeStore((s) => s.preserveThinking);
const setPreserveThinking = useChatRuntimeStore((s) => s.setPreserveThinking);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
@ -350,6 +385,8 @@ export function SharedComposer({
const setImageToolsEnabled = useChatRuntimeStore(
(s) => s.setImageToolsEnabled,
);
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled);
const webFetchToolsEnabled = useChatRuntimeStore(
(s) => s.webFetchToolsEnabled,
);
@ -481,6 +518,7 @@ export function SharedComposer({
// Images pill is only ever lit on OpenAI cloud's Responses-API models
// and Gemini Nano Banana family. No local tool runtime fallback.
const showImagePill = supportsBuiltinImageGeneration;
const artifactDisabled = !modelLoaded;
// Fetch pill: Anthropic-only (web_fetch_20250910 / web_fetch_20260209).
const webFetchDisabled = !modelLoaded || !supportsBuiltinWebFetch;
const showWebFetchPill = supportsBuiltinWebFetch;
@ -488,12 +526,17 @@ export function SharedComposer({
// reference `toolsDisabled` (rare; both pills used it before).
const toolsDisabled = codeDisabled;
const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio);
const clearPendingAudioStore = useChatRuntimeStore((s) => s.clearPendingAudio);
const { isDictating, start: startDictation, stop: stopDictation, supported: dictationSupported } = useDictation(
setText,
const clearPendingAudioStore = useChatRuntimeStore(
(s) => s.clearPendingAudio,
);
const {
isDictating,
start: startDictation,
stop: stopDictation,
supported: dictationSupported,
} = useDictation(setText);
useEffect(() => {
const id = setInterval(() => {
const handles = handlesRef.current;
@ -510,43 +553,48 @@ export function SharedComposer({
ta.style.height = "auto";
const styles = window.getComputedStyle(ta);
const lineHeight = parseFloat(styles.lineHeight) || 20;
const paddingY = parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
const borderY = parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
const paddingY =
parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
const borderY =
parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
const maxHeight = lineHeight * 6 + paddingY + borderY;
const next = Math.min(ta.scrollHeight, maxHeight);
ta.style.height = `${next}px`;
ta.style.overflowY = ta.scrollHeight > maxHeight ? "auto" : "hidden";
}, [text]);
const addFiles = useCallback((files: FileList | null) => {
if (!files?.length) return;
const next: PendingImage[] = [];
let droppedImageForUnavailable = false;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file) continue;
// Handle audio files
if (file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) {
fileToBase64(file).then((base64) => {
setPendingAudio({ name: file.name, base64 });
setPendingAudioStore(base64, file.name);
});
continue;
const addFiles = useCallback(
(files: FileList | null) => {
if (!files?.length) return;
const next: PendingImage[] = [];
let droppedImageForUnavailable = false;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file) continue;
// Handle audio files
if (file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) {
fileToBase64(file).then((base64) => {
setPendingAudio({ name: file.name, base64 });
setPendingAudioStore(base64, file.name);
});
continue;
}
// Handle image files
if (!file.type.match(/^image\/(jpeg|png|webp|gif)$/i)) continue;
if (file.size > MAX_IMAGE_SIZE) continue;
if (attachUnavailableReason) {
droppedImageForUnavailable = true;
continue;
}
next.push({ id: crypto.randomUUID(), file });
}
// Handle image files
if (!file.type.match(/^image\/(jpeg|png|webp|gif)$/i)) continue;
if (file.size > MAX_IMAGE_SIZE) continue;
if (attachUnavailableReason) {
droppedImageForUnavailable = true;
continue;
if (droppedImageForUnavailable && attachUnavailableReason) {
toast.error(attachUnavailableReason);
}
next.push({ id: crypto.randomUUID(), file });
}
if (droppedImageForUnavailable && attachUnavailableReason) {
toast.error(attachUnavailableReason);
}
setPendingImages((prev) => [...prev, ...next]);
}, [setPendingAudioStore, attachUnavailableReason]);
setPendingImages((prev) => [...prev, ...next]);
},
[setPendingAudioStore, attachUnavailableReason],
);
const removePendingImage = useCallback((id: string) => {
setPendingImages((prev) => prev.filter((p) => p.id !== id));
@ -604,12 +652,17 @@ export function SharedComposer({
// LoraCompare and single-pane chats are unaffected.
if (hasCompareHandles && !isGeneralizedCompare) {
toast.error("Pick a model in each pane to compare", {
description: "Use the model dropdown above each pane, then send your prompt.",
description:
"Use the model dropdown above each pane, then send your prompt.",
});
return;
}
if (pendingImages.length > 0 && !isGeneralizedCompare && imageUnavailableReason) {
if (
pendingImages.length > 0 &&
!isGeneralizedCompare &&
imageUnavailableReason
) {
// Single mode: the loaded model's runtime capability is known
// here. Compare mode defers — each ensureModelLoaded below sets
// loadedIsMultimodal for its side, and the chat-adapter's
@ -647,8 +700,9 @@ export function SharedComposer({
const maxSeqLength = store.params.maxSeqLength;
const trustRemoteCode = store.params.trustRemoteCode ?? false;
const chatTemplateOverride = store.chatTemplateOverride;
const effectiveChatTemplateOverride =
chatTemplateOverride?.trim() ? chatTemplateOverride : null;
const effectiveChatTemplateOverride = chatTemplateOverride?.trim()
? chatTemplateOverride
: null;
function modelDisplayName(id: string): string {
const parts = id.split("/");
@ -656,11 +710,14 @@ export function SharedComposer({
}
// Helper: load a model and update store checkpoint
async function ensureModelLoaded(sel: CompareModelSelection): Promise<string> {
async function ensureModelLoaded(
sel: CompareModelSelection,
): Promise<string> {
const currentStore = useChatRuntimeStore.getState();
const isAlreadyActive =
currentStore.params.checkpoint === sel.id &&
(currentStore.activeGgufVariant ?? null) === (sel.ggufVariant ?? null);
(currentStore.activeGgufVariant ?? null) ===
(sel.ggufVariant ?? null);
if (!isAlreadyActive) {
const validation = await validateModel({
model_path: sel.id,
@ -750,9 +807,17 @@ export function SharedComposer({
try {
// Side 1: load → generate → wait
if (handle1 && model1?.id) {
toast("Loading Model 1…", { id: toastId, description: name1, duration: Infinity });
toast("Loading Model 1…", {
id: toastId,
description: name1,
duration: Infinity,
});
const status1 = await ensureModelLoaded(model1);
toast("Generating with Model 1…", { id: toastId, description: `${name1} (${status1})`, duration: Infinity });
toast("Generating with Model 1…", {
id: toastId,
description: `${name1} (${status1})`,
duration: Infinity,
});
const done = handle1.waitForRunEnd();
handle1.startRun();
await done;
@ -760,13 +825,22 @@ export function SharedComposer({
// Side 2: load → generate → wait
if (handle2 && model2?.id) {
const needsLoad = model2.id.toLowerCase() !== (model1?.id || "").toLowerCase()
|| (model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? "");
const needsLoad =
model2.id.toLowerCase() !== (model1?.id || "").toLowerCase() ||
(model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? "");
if (needsLoad) {
toast("Loading Model 2…", { id: toastId, description: name2, duration: Infinity });
toast("Loading Model 2…", {
id: toastId,
description: name2,
duration: Infinity,
});
}
const status2 = await ensureModelLoaded(model2);
toast("Generating with Model 2…", { id: toastId, description: `${name2} (${status2})`, duration: Infinity });
toast("Generating with Model 2…", {
id: toastId,
description: `${name2} (${status2})`,
duration: Infinity,
});
const done = handle2.waitForRunEnd();
handle2.startRun();
await done;
@ -820,7 +894,12 @@ export function SharedComposer({
}
}
const canSend = (text.trim().length > 0 || pendingImages.length > 0 || pendingAudio !== null) && !busy && !isComposing;
const canSend =
(text.trim().length > 0 ||
pendingImages.length > 0 ||
pendingAudio !== null) &&
!busy &&
!isComposing;
return (
<div
@ -855,7 +934,10 @@ export function SharedComposer({
<span className="max-w-48 truncate">{pendingAudio.name}</span>
<button
type="button"
onClick={() => { setPendingAudio(null); clearPendingAudioStore(); }}
onClick={() => {
setPendingAudio(null);
clearPendingAudioStore();
}}
className="flex size-4 items-center justify-center rounded-full hover:bg-destructive hover:text-destructive-foreground"
aria-label="Remove audio"
>
@ -953,130 +1035,136 @@ export function SharedComposer({
)}
{showReasoningControl ? (
effectiveReasoningStyle === "reasoning_effort" ? (
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
disabled={reasoningDisabled}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningDisabled
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
disabled={reasoningDisabled}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningVisualEnabled
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={thinkEffortAriaLabel({
modelLoaded,
reasoningDisabled,
reasoningEffort,
})}
>
{effectiveReasoningVisualEnabled ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>
Think:{" "}
{effectiveReasoningVisualEnabled
? formatReasoningEffortLabel(
reasoningEffort,
externalSelection?.modelId,
)
: formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{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);
setReasoningEnabled(true);
applyQwenThinkingParams(true);
// Mutual exclusion: turning thinking on for a
// Kimi model forces the web_search builtin off.
if (isKimiExternal && toolsEnabled) {
setToolsEnabled(false, { persist: false });
}
}}
>
{formatReasoningEffortLabel(
level,
externalSelection?.modelId,
)}
{effectiveReasoningVisualEnabled &&
reasoningEffort === level
? " \u2713"
: ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
disabled={reasoningDisabled || reasoningLockedOn}
aria-disabled={reasoningDisabled || reasoningLockedOn}
title={
reasoningLockedOn
? "This model requires reasoning to stay on."
: undefined
}
onClick={() => {
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, { persist: false });
}
}}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningLockedOn
? "cursor-not-allowed text-primary"
: reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningVisualEnabled
: effectiveReasoningEnabled
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={thinkEffortAriaLabel({
modelLoaded,
reasoningDisabled,
reasoningEffort,
})}
>
{effectiveReasoningVisualEnabled ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>
Think:{" "}
{effectiveReasoningVisualEnabled
? formatReasoningEffortLabel(
reasoningEffort,
externalSelection?.modelId,
)
: formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{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);
setReasoningEnabled(true);
applyQwenThinkingParams(true);
// Mutual exclusion: turning thinking on for a
// Kimi model forces the web_search builtin off.
if (isKimiExternal && toolsEnabled) {
setToolsEnabled(false, { persist: false });
}
}}
>
{formatReasoningEffortLabel(level, externalSelection?.modelId)}
{effectiveReasoningVisualEnabled && reasoningEffort === level ? " \u2713" : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
disabled={reasoningDisabled || reasoningLockedOn}
aria-disabled={reasoningDisabled || reasoningLockedOn}
title={
reasoningLockedOn
? "This model requires reasoning to stay on."
: undefined
}
onClick={() => {
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, { persist: false });
}
}}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningLockedOn
? "cursor-not-allowed text-primary"
: reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningEnabled
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={thinkToggleAriaLabel({
reasoningLockedOn,
modelLoaded,
reasoningDisabled,
effectiveReasoningEnabled,
})}
>
{reasoningLockedOn ||
(effectiveReasoningEnabled && !reasoningDisabled) ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>Think</span>
</button>
aria-label={thinkToggleAriaLabel({
reasoningLockedOn,
modelLoaded,
reasoningDisabled,
effectiveReasoningEnabled,
})}
>
{reasoningLockedOn ||
(effectiveReasoningEnabled && !reasoningDisabled) ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>Think</span>
</button>
)
) : null}
{supportsPreserveThinking && (
@ -1093,7 +1181,9 @@ export function SharedComposer({
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={
preserveThinking ? "Disable preserve think" : "Enable preserve think"
preserveThinking
? "Disable preserve think"
: "Enable preserve think"
}
>
{preserveThinking && modelLoaded ? (
@ -1122,7 +1212,9 @@ export function SharedComposer({
}}
className="composer-pill-btn"
data-active={toolsEnabled && !searchDisabled ? "true" : "false"}
aria-label={toolsEnabled ? "Disable web search" : "Enable web search"}
aria-label={
toolsEnabled ? "Disable web search" : "Enable web search"
}
>
<GlobeIcon className="size-3.5" />
<span>Search</span>
@ -1133,7 +1225,11 @@ export function SharedComposer({
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
className="composer-pill-btn"
data-active={codeToolsEnabled && !codeDisabled ? "true" : "false"}
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
aria-label={
codeToolsEnabled
? "Disable code execution"
: "Enable code execution"
}
>
<CodeToggleIcon className="size-3.5" />
<span>Code</span>
@ -1144,9 +1240,13 @@ export function SharedComposer({
disabled={imageDisabled}
onClick={() => setImageToolsEnabled(!imageToolsEnabled)}
className="composer-pill-btn"
data-active={imageToolsEnabled && !imageDisabled ? "true" : "false"}
data-active={
imageToolsEnabled && !imageDisabled ? "true" : "false"
}
aria-label={
imageToolsEnabled ? "Disable image generation" : "Enable image generation"
imageToolsEnabled
? "Disable image generation"
: "Enable image generation"
}
>
<HugeiconsIcon
@ -1157,6 +1257,21 @@ export function SharedComposer({
<span>Images</span>
</button>
)}
<button
type="button"
disabled={artifactDisabled}
onClick={() => setArtifactsEnabled(!artifactsEnabled)}
className="composer-pill-btn"
data-active={
artifactsEnabled && !artifactDisabled ? "true" : "false"
}
aria-label={
artifactsEnabled ? "Disable artifacts" : "Enable artifacts"
}
>
<FileTextIcon className="size-3.5" />
<span>Artifacts</span>
</button>
{showWebFetchPill && (
<button
type="button"

View file

@ -27,6 +27,11 @@ export const CHAT_REASONING_ENABLED_KEY = "unsloth_chat_reasoning_enabled";
export const CHAT_TOOLS_ENABLED_KEY = "unsloth_chat_tools_enabled";
export const CHAT_CODE_TOOLS_ENABLED_KEY = "unsloth_chat_code_tools_enabled";
export const CHAT_IMAGE_TOOLS_ENABLED_KEY = "unsloth_chat_image_tools_enabled";
export const CHAT_ARTIFACTS_ENABLED_KEY = "unsloth_chat_artifacts_enabled";
export const CHAT_COLLAPSE_HTML_ARTIFACTS_KEY =
"unsloth_chat_collapse_html_artifacts";
export const CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY =
"unsloth_chat_allow_artifact_network_access";
export const CHAT_MCP_ENABLED_KEY = "unsloth_chat_mcp_enabled";
export const CHAT_WEB_FETCH_TOOLS_ENABLED_KEY =
"unsloth_chat_web_fetch_tools_enabled";
@ -300,6 +305,9 @@ type ChatRuntimeStore = {
toolsEnabled: boolean;
codeToolsEnabled: boolean;
imageToolsEnabled: boolean;
artifactsEnabled: boolean;
collapseHtmlArtifacts: boolean;
allowArtifactNetworkAccess: boolean;
mcpEnabledForChat: boolean;
/**
* Fetch pill state, independent of `toolsEnabled` (Search). Only
@ -368,6 +376,12 @@ type ChatRuntimeStore = {
setToolsEnabled: (enabled: boolean, options?: { persist?: boolean }) => void;
setCodeToolsEnabled: (enabled: boolean) => void;
setImageToolsEnabled: (enabled: boolean) => void;
setArtifactsEnabled: (
enabled: boolean,
options?: { persist?: boolean },
) => void;
setCollapseHtmlArtifacts: (enabled: boolean) => void;
setAllowArtifactNetworkAccess: (enabled: boolean) => void;
setMcpEnabledForChat: (enabled: boolean) => void;
setWebFetchToolsEnabled: (enabled: boolean) => void;
setToolStatus: (status: string | null) => void;
@ -400,6 +414,8 @@ type ScalarSettingKey =
| "autoTitle"
| "reasoningEffort"
| "preserveThinking"
| "collapseHtmlArtifacts"
| "allowArtifactNetworkAccess"
| "autoHealToolCalls"
| "maxToolCallsPerMessage"
| "toolCallTimeout";
@ -434,6 +450,8 @@ const SCALAR_SETTING_KEYS = [
"autoTitle",
"reasoningEffort",
"preserveThinking",
"collapseHtmlArtifacts",
"allowArtifactNetworkAccess",
"autoHealToolCalls",
"maxToolCallsPerMessage",
"toolCallTimeout",
@ -619,6 +637,12 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
toolsEnabled: loadBool(CHAT_TOOLS_ENABLED_KEY, false),
codeToolsEnabled: loadBool(CHAT_CODE_TOOLS_ENABLED_KEY, false),
imageToolsEnabled: loadBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, false),
artifactsEnabled: loadBool(CHAT_ARTIFACTS_ENABLED_KEY, false),
collapseHtmlArtifacts: loadBool(CHAT_COLLAPSE_HTML_ARTIFACTS_KEY, false),
allowArtifactNetworkAccess: loadBool(
CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY,
false,
),
mcpEnabledForChat: loadBool(CHAT_MCP_ENABLED_KEY, false),
webFetchToolsEnabled: loadBool(CHAT_WEB_FETCH_TOOLS_ENABLED_KEY, false),
toolStatus: null,
@ -835,6 +859,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
toolsEnabled: false,
codeToolsEnabled: false,
imageToolsEnabled: false,
artifactsEnabled: false,
mcpEnabledForChat: false,
webFetchToolsEnabled: false,
toolStatus: null,
kvCacheDtype: null,
@ -896,6 +922,36 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
saveBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, imageToolsEnabled);
return { imageToolsEnabled };
}),
setArtifactsEnabled: (artifactsEnabled, options) =>
set(() => {
if (options?.persist !== false) {
saveBool(CHAT_ARTIFACTS_ENABLED_KEY, artifactsEnabled);
}
return { artifactsEnabled };
}),
setCollapseHtmlArtifacts: (collapseHtmlArtifacts) =>
set((state) => {
saveBool(CHAT_COLLAPSE_HTML_ARTIFACTS_KEY, collapseHtmlArtifacts);
setScalarSettingVersion(
"collapseHtmlArtifacts",
collapseHtmlArtifacts,
state.collapseHtmlArtifacts,
);
return { collapseHtmlArtifacts };
}),
setAllowArtifactNetworkAccess: (allowArtifactNetworkAccess) =>
set((state) => {
saveBool(
CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY,
allowArtifactNetworkAccess,
);
setScalarSettingVersion(
"allowArtifactNetworkAccess",
allowArtifactNetworkAccess,
state.allowArtifactNetworkAccess,
);
return { allowArtifactNetworkAccess };
}),
setMcpEnabledForChat: (mcpEnabledForChat) =>
set(() => {
saveBool(CHAT_MCP_ENABLED_KEY, mcpEnabledForChat);

View file

@ -28,6 +28,9 @@ const CHAT_ACTIVE_PRESET_KEY = "unsloth_chat_active_preset";
const CHAT_ACTIVE_PRESET_SOURCE_KEY = "unsloth_chat_active_preset_source";
const REASONING_EFFORT_KEY = "unsloth_reasoning_effort";
const PRESERVE_THINKING_KEY = "unsloth_preserve_thinking";
const COLLAPSE_HTML_ARTIFACTS_KEY = "unsloth_chat_collapse_html_artifacts";
const ALLOW_ARTIFACT_NETWORK_ACCESS_KEY =
"unsloth_chat_allow_artifact_network_access";
const CHAT_PRESETS_KEY = "unsloth_chat_custom_presets";
const LEGACY_CHAT_SYSTEM_PROMPTS_KEY = "unsloth_chat_system_prompts";
const LEGACY_CHAT_SETTINGS_IMPORT_KEY =
@ -216,6 +219,10 @@ function sanitizeChatSettings(value: unknown): PersistedChatSettings {
const reasoningEffort = sanitizeReasoningEffort(value.reasoningEffort);
const autoTitle = sanitizeBool(value.autoTitle);
const preserveThinking = sanitizeBool(value.preserveThinking);
const collapseHtmlArtifacts = sanitizeBool(value.collapseHtmlArtifacts);
const allowArtifactNetworkAccess = sanitizeBool(
value.allowArtifactNetworkAccess,
);
const autoHealToolCalls = sanitizeBool(value.autoHealToolCalls);
const maxToolCallsPerMessage = sanitizeInt(value.maxToolCallsPerMessage, 1);
const toolCallTimeout = sanitizeInt(value.toolCallTimeout, 1);
@ -230,6 +237,12 @@ function sanitizeChatSettings(value: unknown): PersistedChatSettings {
if (reasoningEffort) settings.reasoningEffort = reasoningEffort;
if (preserveThinking !== undefined)
settings.preserveThinking = preserveThinking;
if (collapseHtmlArtifacts !== undefined) {
settings.collapseHtmlArtifacts = collapseHtmlArtifacts;
}
if (allowArtifactNetworkAccess !== undefined) {
settings.allowArtifactNetworkAccess = allowArtifactNetworkAccess;
}
if (autoHealToolCalls !== undefined) {
settings.autoHealToolCalls = autoHealToolCalls;
}
@ -290,6 +303,8 @@ export function isEmptyChatSettings(settings: PersistedChatSettings): boolean {
settings.autoTitle === undefined &&
settings.reasoningEffort === undefined &&
settings.preserveThinking === undefined &&
settings.collapseHtmlArtifacts === undefined &&
settings.allowArtifactNetworkAccess === undefined &&
settings.autoHealToolCalls === undefined &&
settings.maxToolCallsPerMessage === undefined &&
settings.toolCallTimeout === undefined
@ -318,6 +333,8 @@ export function loadLegacyChatSettings(): PersistedChatSettings {
);
const autoTitle = loadBool(AUTO_TITLE_KEY);
const preserveThinking = loadBool(PRESERVE_THINKING_KEY);
const collapseHtmlArtifacts = loadBool(COLLAPSE_HTML_ARTIFACTS_KEY);
const allowArtifactNetworkAccess = loadBool(ALLOW_ARTIFACT_NETWORK_ACCESS_KEY);
const autoHealToolCalls = loadBool(AUTO_HEAL_TOOL_CALLS_KEY);
const maxToolCallsPerMessage = loadInt(MAX_TOOL_CALLS_KEY, 1);
const toolCallTimeout = loadInt(TOOL_CALL_TIMEOUT_KEY, 1);
@ -336,6 +353,12 @@ export function loadLegacyChatSettings(): PersistedChatSettings {
if (reasoningEffort) settings.reasoningEffort = reasoningEffort;
if (preserveThinking !== undefined)
settings.preserveThinking = preserveThinking;
if (collapseHtmlArtifacts !== undefined) {
settings.collapseHtmlArtifacts = collapseHtmlArtifacts;
}
if (allowArtifactNetworkAccess !== undefined) {
settings.allowArtifactNetworkAccess = allowArtifactNetworkAccess;
}
if (autoHealToolCalls !== undefined) {
settings.autoHealToolCalls = autoHealToolCalls;
}

View file

@ -0,0 +1,11 @@
// 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 { NativeModelChip } from "./components/native-model-chip";
export { NativeModelDropOverlay } from "./components/native-model-drop-overlay";
export { useNativeIntentStore } from "./store";
export type { NativeIntent } from "./types";
export { useChooseNativeModel } from "./use-native-dialogs";
export { useNativeModelDrop } from "./use-native-drop";
export type { NativeModelDropState } from "./use-native-drop";
export { useNativePathLeasesSupported } from "./use-native-readiness";

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { toast } from "@/lib/toast";
import {
Dialog,
@ -15,6 +16,7 @@ import {
clearAllChats,
countAllChats,
downloadChatExport,
useChatRuntimeStore,
} from "@/features/chat";
import { useT } from "@/i18n";
import { Delete02Icon, Download02Icon } from "@hugeicons/core-free-icons";
@ -29,10 +31,26 @@ export function ChatTab() {
const [count, setCount] = useState<number | null>(null);
const [exporting, setExporting] = useState(false);
const [clearing, setClearing] = useState(false);
const collapseHtmlArtifacts = useChatRuntimeStore(
(state) => state.collapseHtmlArtifacts,
);
const setCollapseHtmlArtifacts = useChatRuntimeStore(
(state) => state.setCollapseHtmlArtifacts,
);
const allowArtifactNetworkAccess = useChatRuntimeStore(
(state) => state.allowArtifactNetworkAccess,
);
const setAllowArtifactNetworkAccess = useChatRuntimeStore(
(state) => state.setAllowArtifactNetworkAccess,
);
const hydratePersistedSettings = useChatRuntimeStore(
(state) => state.hydratePersistedSettings,
);
useEffect(() => {
void countAllChats().then(setCount);
}, []);
void hydratePersistedSettings();
}, [hydratePersistedSettings]);
const handleExport = async () => {
setExporting(true);
@ -111,6 +129,31 @@ export function ChatTab() {
</p>
</header>
<SettingsSection title={t("settings.chat.artifacts.title")}>
<SettingsRow
label={t("settings.chat.artifacts.collapseHtmlBlocks")}
description={t(
"settings.chat.artifacts.collapseHtmlBlocksDescription",
)}
>
<Switch
checked={collapseHtmlArtifacts}
onCheckedChange={setCollapseHtmlArtifacts}
/>
</SettingsRow>
<SettingsRow
label={t("settings.chat.artifacts.allowNetworkAccess")}
description={t(
"settings.chat.artifacts.allowNetworkAccessDescription",
)}
>
<Switch
checked={allowArtifactNetworkAccess}
onCheckedChange={setAllowArtifactNetworkAccess}
/>
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.chat.data")}>
<SettingsRow
label={t("settings.chat.exportHistory")}

View file

@ -165,6 +165,15 @@ export const en = {
chat: {
title: "Chat",
description: "Manage your chat history stored on this device.",
artifacts: {
title: "Artifacts",
collapseHtmlBlocks: "Collapse HTML blocks",
collapseHtmlBlocksDescription:
"Artifacts mode collapses full HTML fallback automatically. Turn this on to also collapse full fenced HTML documents when Artifacts is off.",
allowNetworkAccess: "Allow artifact network access",
allowNetworkAccessDescription:
"Let artifact previews load scripts, styles, fonts, media, fetch, and WebSocket resources from HTTP(S) CDNs. Keep off for fully offline previews.",
},
data: "Data",
exportHistory: "Export chat history",
exportHistoryDescription:

View file

@ -818,6 +818,149 @@
font-family: var(--font-sans);
}
.chat-artifact-split[data-artifact-layout-animating="true"] > [data-panel] {
transition:
flex-basis 260ms var(--ease-out-cubic),
flex-grow 260ms var(--ease-out-cubic),
flex-shrink 260ms var(--ease-out-cubic);
will-change: flex-basis, flex-grow;
}
.chat-artifact-pop-surface {
pointer-events: none;
opacity: 0;
transform: scale(0.965) translateY(8px);
transform-origin: center center;
transition:
opacity 180ms var(--ease-out-cubic),
transform 220ms var(--ease-out-cubic);
will-change: opacity, transform;
}
.chat-artifact-pop-surface[data-artifact-surface-visible="true"] {
pointer-events: auto;
opacity: 1;
transform: scale(1) translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.chat-artifact-pop-surface {
transition: none;
transform: none;
}
}
.artifact-card-shimmer {
background: linear-gradient(
105deg,
transparent 0%,
color-mix(in oklch, var(--muted-foreground) 4%, transparent) 40%,
color-mix(in oklch, var(--muted-foreground) 8%, transparent) 50%,
color-mix(in oklch, var(--muted-foreground) 4%, transparent) 60%,
transparent 100%
);
transform: translateX(-120%);
animation: artifact-card-shimmer 1.55s var(--ease-out-cubic) infinite;
}
.artifact-loading-line {
width: 48%;
background: color-mix(in oklch, var(--primary) 88%, transparent);
transform: translate3d(-125%, 0, 0) scaleX(0.78);
transform-origin: center center;
animation: artifact-loading-line 1.7s linear infinite;
}
.artifact-panel-shell::before,
.artifact-panel-shell::after {
content: "";
position: absolute;
z-index: 20;
pointer-events: none;
transition:
border-color 150ms var(--ease-out-cubic),
background-color 150ms var(--ease-out-cubic),
box-shadow 150ms var(--ease-out-cubic),
transform 150ms var(--ease-out-cubic);
}
.artifact-panel-shell::before {
inset: 0;
border: 2px solid transparent;
border-radius: inherit;
-webkit-mask: linear-gradient(90deg, #000 0 24px, transparent 24px);
mask: linear-gradient(90deg, #000 0 24px, transparent 24px);
}
.artifact-panel-shell::after {
top: 50%;
left: -2px;
height: 28px;
width: 4px;
border-radius: 999px;
background: transparent;
transform: translateY(-50%);
}
[data-slot="resizable-handle"]:hover
+ [data-slot="resizable-panel"]
.artifact-panel-shell::before,
[data-slot="resizable-handle"]:active
+ [data-slot="resizable-panel"]
.artifact-panel-shell::before,
[data-slot="resizable-handle"][data-resize-handle-state="hover"]
+ [data-slot="resizable-panel"]
.artifact-panel-shell::before,
[data-slot="resizable-handle"][data-resize-handle-state="drag"]
+ [data-slot="resizable-panel"]
.artifact-panel-shell::before {
border-color: color-mix(in oklch, var(--primary) 58%, var(--border));
}
[data-slot="resizable-handle"]:hover
+ [data-slot="resizable-panel"]
.artifact-panel-shell::after,
[data-slot="resizable-handle"]:active
+ [data-slot="resizable-panel"]
.artifact-panel-shell::after,
[data-slot="resizable-handle"][data-resize-handle-state="hover"]
+ [data-slot="resizable-panel"]
.artifact-panel-shell::after,
[data-slot="resizable-handle"][data-resize-handle-state="drag"]
+ [data-slot="resizable-panel"]
.artifact-panel-shell::after {
background: color-mix(in oklch, var(--primary) 58%, var(--border));
box-shadow: none;
transform: translateY(-50%) scaleY(1.06);
}
@keyframes artifact-card-shimmer {
to {
transform: translateX(120%);
}
}
@keyframes artifact-loading-line {
0% {
transform: translate3d(-125%, 0, 0) scaleX(0.78);
}
35% {
transform: translate3d(-18%, 0, 0) scaleX(0.96);
}
62% {
transform: translate3d(72%, 0, 0) scaleX(1.12);
}
82% {
transform: translate3d(132%, 0, 0) scaleX(1.26);
}
94% {
transform: translate3d(188%, 0, 0) scaleX(1.36);
}
100% {
transform: translate3d(220%, 0, 0) scaleX(1.42);
}
}
/* Fine-tuning Studio: equal default height, expandable when needed (md+) */
.min-h-studio-config-column {
@apply md:min-h-[470px];

View file

@ -169,6 +169,11 @@ DEFAULT_MAX_MACOS_RELEASE_FALLBACKS = env_int(
16,
minimum = 1,
)
# Deterministic macOS pin. At b9428 ggml-org's macOS runner moved to macOS 26
# (Tahoe), so b9428+ prebuilts only load on macOS 26+. b9415 is the last build
# stamped below 26 (arm64 minos 14, x64 minos 13.3); loads on macOS 13.3/14/15/26.
_PINNED_MACOS_FALLBACK_TAG = "b9415"
_PINNED_MACOS_LATEST_FLOOR = (26, 0)
FORCE_COMPILE_DEFAULT_REF = os.environ.get("UNSLOTH_LLAMA_FORCE_COMPILE_REF", "master")
DIRECT_LINUX_BUNDLE_PROFILES: dict[str, dict[str, Any]] = {
@ -1754,6 +1759,24 @@ def direct_upstream_release_plan(
)
def pinned_macos_release_tag(host: HostInfo, repo: str) -> str | None:
"""Pin b9415 (the last upstream macOS build that loads below macOS 26) for a
known pre-26 host on ggml-org upstream; return None to keep latest selection.
The unslothai/llama.cpp fork ships its own prebuilts (arm64 minos 14, x64
minos 13.3) and needs no pin, so this is a no-op there and for macOS 26+,
unknown version, non-macOS."""
if repo != UPSTREAM_REPO:
return None
if not host.is_macos:
return None
version = host.macos_version
if version is None:
return None
if version >= _PINNED_MACOS_LATEST_FLOOR:
return None
return _PINNED_MACOS_FALLBACK_TAG
def resolve_simple_install_release_plans(
llama_tag: str,
host: HostInfo,
@ -1767,15 +1790,15 @@ def resolve_simple_install_release_plans(
allow_older_release_fallback = (
requested_tag == "latest" and not published_release_tag
)
# macOS: pin the last upstream build that loads on a pre-26 host instead of
# fetching the latest (macOS 26 only) build and walking back release by
# release. No-op on macOS 26+, unknown version, non-macOS, and the fork.
if allow_older_release_fallback:
pinned_macos = pinned_macos_release_tag(host, repo)
if pinned_macos is not None:
requested_tag = pinned_macos
allow_older_release_fallback = False
release_limit = max(1, max_release_fallbacks)
# macOS may need to walk past a run of too-new prebuilts. Only when the host
# version is known; otherwise keep the default (cannot tell up front).
if (
host.is_macos
and allow_older_release_fallback
and host.macos_version is not None
):
release_limit = max(release_limit, DEFAULT_MAX_MACOS_RELEASE_FALLBACKS)
plans: list[InstallReleasePlan] = []
last_error: PrebuiltFallback | None = None
@ -5441,9 +5464,10 @@ def preflight_macos_installed_binaries(
install_dir: Path,
host: HostInfo,
) -> None:
"""Reject a macos prebuilt whose minimum-OS is newer than the host so the
release walk-back advances to the newest compatible release. No-op when the
host macOS version is unknown (runtime validation remains the backstop)."""
"""Reject a macos prebuilt whose minimum-OS is newer than the host. The
upstream selector pins a loadable release up front, so here this is the
post-download backstop; the published/fork path also uses it to advance the
walk-back. No-op when the host macOS version is unknown (runtime validates)."""
if not host.is_macos or host.macos_version is None:
return
issues = macos_binary_minos_issues(binaries, install_dir, host)

View file

@ -563,6 +563,80 @@ else
FAIL=$((FAIL + 1))
fi
echo ""
echo "=== Apple Silicon x86_64 (Rosetta) venv rebuild ==="
# Extract the real guard block from install.sh so we exercise the shipped logic
# (comment header down to its column-0 closing fi).
_GUARD_FILE=$(mktemp)
awk '/Guard against two independent Apple Silicon venv problems/{f=1} f{print} f&&/^fi$/{exit}' \
"$INSTALL_SH" > "$_GUARD_FILE"
if [ ! -s "$_GUARD_FILE" ]; then
echo " FAIL: could not extract Apple Silicon venv guard from install.sh"
FAIL=$((FAIL + 1))
else
# Runner: stub uv (via run_install_cmd) + a fake venv python, source the
# guard, then print "<final_arch> <final_ver> | <recreate_selectors>".
# The stub maps a uv arm64 selector to the interpreter uv would produce:
# cpython-3.12-* -> arm64 3.12.7, cpython-3.13-* -> arm64 $REBUILD_313_VERSION.
_RUNNER=$(mktemp)
cat > "$_RUNNER" << 'RUNNER_EOF'
GUARD="$1"; VENV_DIR="$2"
make_python() { # dir machine version
mkdir -p "$1/bin"
printf '#!/usr/bin/env bash\necho "%s %s"\n' "$2" "$3" > "$1/bin/python"
chmod +x "$1/bin/python"
}
RECREATE_LOG=$(mktemp); : > "$RECREATE_LOG"
run_install_cmd() {
shift # drop the human label
if [ "$1" = "uv" ] && [ "$2" = "venv" ]; then
dir="$3"; sel=""; shift 3
while [ $# -gt 0 ]; do [ "$1" = "--python" ] && { sel="$2"; shift; }; shift; done
echo "$sel" >> "$RECREATE_LOG"
case "$sel" in
*3.12-macos-aarch64*) make_python "$dir" arm64 "3.12.7" ;;
*3.13-macos-aarch64*) make_python "$dir" arm64 "${REBUILD_313_VERSION:-3.13.3}" ;;
*) make_python "$dir" arm64 "$sel" ;;
esac
fi
}
[ "$INIT_ARCH" != none ] && make_python "$VENV_DIR" "$INIT_ARCH" "$INIT_VER"
PYTHON_VERSION="3.13"
. "$GUARD" >&2 # guard's user-facing echoes go to stderr; keep stdout clean
final="none"; [ -x "$VENV_DIR/bin/python" ] && final="$("$VENV_DIR/bin/python" -c x)"
printf '%s | %s\n' "$final" "$(paste -sd, "$RECREATE_LOG" 2>/dev/null)"
rm -f "$RECREATE_LOG"
RUNNER_EOF
_run_guard() { # _USER_PYTHON OS _ARCH INIT_ARCH INIT_VER REBUILD_313_VERSION
_vd=$(mktemp -d)
env _USER_PYTHON="$1" OS="$2" _ARCH="$3" INIT_ARCH="$4" INIT_VER="$5" \
REBUILD_313_VERSION="$6" bash "$_RUNNER" "$_GUARD_FILE" "$_vd/venv"
rm -rf "$_vd"
}
assert_eq "clean arm64 venv left untouched" \
"arm64 3.13.3 | " "$(_run_guard '' macos arm64 arm64 3.13.3 '')"
assert_eq "x86_64 venv rebuilt as arm64" \
"arm64 3.13.3 | cpython-3.13-macos-aarch64-none" \
"$(_run_guard '' macos arm64 x86_64 3.13.3 '')"
assert_eq "x86_64 venv that lands on 3.13.8 is rebuilt then downgraded to 3.12" \
"arm64 3.12.7 | cpython-3.13-macos-aarch64-none,cpython-3.12-macos-aarch64-none" \
"$(_run_guard '' macos arm64 x86_64 3.13.3 3.13.8)"
assert_eq "arm64 3.13.8 venv downgraded to 3.12" \
"arm64 3.12.7 | cpython-3.12-macos-aarch64-none" \
"$(_run_guard '' macos arm64 arm64 3.13.8 '')"
assert_eq "--python override skips the guard entirely" \
"x86_64 3.13.3 | " "$(_run_guard 3.11 macos arm64 x86_64 3.13.3 '')"
assert_eq "x86_64 host (Intel/Rosetta shell) is a no-op here" \
"x86_64 3.13.3 | " "$(_run_guard '' macos x86_64 x86_64 3.13.3 '')"
rm -f "$_RUNNER"
fi
rm -f "$_GUARD_FILE"
echo ""
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1

View file

@ -238,32 +238,46 @@ def _fake_macos_releases(tags):
]
class TestMacosReleaseWalkback:
"""A known-version macOS host must generate enough older-release plans to
walk back past a run of too-new prebuilts; unknown-version and non-macOS
hosts keep the conservative 2-release default."""
class TestMacosReleasePin:
"""A known pre-26 macOS host deterministically pins the last upstream release
whose prebuilt loads on it (b9415) instead of walking back release by release;
macOS 26+ and unknown-version hosts keep normal latest selection with the
conservative 2-release default."""
TAGS = [f"b{n}" for n in range(9437, 9400, -1)] # 37 newest-first releases
TAGS = [f"b{n}" for n in range(9442, 9400, -1)] # newest-first, includes b9415
def _patch_releases(self, monkeypatch):
monkeypatch.setattr(
ILP,
"iter_release_payloads_by_time",
lambda repo, published_release_tag, requested_tag, host = None: (
_fake_macos_releases(self.TAGS)
),
)
def fake_iter(repo, published_release_tag, requested_tag, host = None):
# The real iterator yields only the requested tag when one is pinned.
if requested_tag and requested_tag != "latest":
return _fake_macos_releases([requested_tag])
return _fake_macos_releases(self.TAGS)
def test_known_macos_host_walks_back_deeper(self, monkeypatch):
monkeypatch.setattr(ILP, "iter_release_payloads_by_time", fake_iter)
def test_pre26_host_pins_b9415(self, monkeypatch):
self._patch_releases(monkeypatch)
_tag, plans = ILP.resolve_simple_install_release_plans(
tag, plans = ILP.resolve_simple_install_release_plans(
"latest",
make_macos_host((14, 0)),
"ggml-org/llama.cpp",
"",
)
assert len(plans) == ILP.DEFAULT_MAX_MACOS_RELEASE_FALLBACKS
assert len(plans) > ILP.DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS
assert tag == ILP._PINNED_MACOS_FALLBACK_TAG == "b9415"
assert len(plans) == 1
assert plans[0].release_tag == "b9415"
def test_tahoe_host_takes_latest(self, monkeypatch):
self._patch_releases(monkeypatch)
tag, plans = ILP.resolve_simple_install_release_plans(
"latest",
make_macos_host((26, 0)),
"ggml-org/llama.cpp",
"",
)
assert tag == "latest"
assert plans[0].release_tag == self.TAGS[0] # newest release
assert len(plans) == ILP.DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS
def test_unknown_macos_host_uses_default(self, monkeypatch):
self._patch_releases(monkeypatch)

View file

@ -85,6 +85,10 @@ _windows_cuda_attempt_covers_blackwell = (
INSTALL_LLAMA_PREBUILT._windows_cuda_attempt_covers_blackwell
)
resolve_release_asset_choice = INSTALL_LLAMA_PREBUILT.resolve_release_asset_choice
pinned_macos_release_tag = INSTALL_LLAMA_PREBUILT.pinned_macos_release_tag
resolve_simple_install_release_plans = (
INSTALL_LLAMA_PREBUILT.resolve_simple_install_release_plans
)
# ---------------------------------------------------------------------------
@ -2631,3 +2635,126 @@ class TestResolveUpstreamAssetChoice:
result = resolve_upstream_asset_choice(host, self.TAG)
assert result.install_kind == "windows-cuda"
assert result.name == cuda_name
# ===========================================================================
# N.2. Deterministic macOS prebuilt pin (b9415)
# ===========================================================================
def _macos_host(machine = "arm64", version = (15, 5)):
return make_host(
system = "Darwin",
machine = machine,
nvidia_smi = None,
driver_cuda_version = None,
compute_caps = [],
has_physical_nvidia = False,
has_usable_nvidia = False,
macos_version = version,
)
class TestPinnedMacosReleaseTag:
"""pinned_macos_release_tag: pin b9415 only for ggml-org upstream macOS hosts
below macOS 26; latest (None) for 26+, unknown version, the fork, non-macOS."""
def test_arm64_sequoia_pins_b9415(self):
host = _macos_host("arm64", (15, 5))
assert pinned_macos_release_tag(host, UPSTREAM_REPO) == "b9415"
def test_arm64_sonoma_pins_b9415(self):
host = _macos_host("arm64", (14, 7))
assert pinned_macos_release_tag(host, UPSTREAM_REPO) == "b9415"
def test_x64_ventura_13_3_pins_b9415(self):
# b9415's Intel slice is minos 13.3, so 13.3 Intel hosts still load it.
host = _macos_host("x86_64", (13, 3))
assert pinned_macos_release_tag(host, UPSTREAM_REPO) == "b9415"
def test_tahoe_26_0_takes_latest(self):
host = _macos_host("arm64", (26, 0))
assert pinned_macos_release_tag(host, UPSTREAM_REPO) is None
def test_tahoe_26_1_takes_latest(self):
host = _macos_host("arm64", (26, 1))
assert pinned_macos_release_tag(host, UPSTREAM_REPO) is None
def test_unknown_version_takes_latest(self):
host = _macos_host("arm64", None)
assert pinned_macos_release_tag(host, UPSTREAM_REPO) is None
def test_fork_repo_is_dormant(self):
# The unslothai/llama.cpp fork publishes its own minos-13.3 prebuilts.
host = _macos_host("arm64", (15, 5))
fork = INSTALL_LLAMA_PREBUILT.DEFAULT_PUBLISHED_REPO
assert pinned_macos_release_tag(host, fork) is None
def test_non_macos_host_is_dormant(self):
host = make_host(system = "Linux", machine = "x86_64")
assert pinned_macos_release_tag(host, UPSTREAM_REPO) is None
class TestResolveSimpleMacosPin:
"""End to end on the simple/upstream path macOS actually uses: a pre-26 host
deterministically resolves b9415 (no walk-back); a macOS 26 host takes the
latest release. Mirrors how setup.sh routes Darwin to ggml-org/llama.cpp."""
TAGS = ["b9442", "b9430", "b9428", "b9415"] # newest-first feed
def _feed(self, monkeypatch):
calls = []
def _release(tag):
name = f"llama-{tag}-bin-macos-arm64.tar.gz"
return {
"tag_name": tag,
"assets": [
{
"name": name,
"browser_download_url": f"https://example.com/{name}",
}
],
}
def fake_iter(repo, published_release_tag = "", requested_tag = "", host = None):
calls.append((repo, published_release_tag, requested_tag))
# Emulate the real iterator: a specific tag yields only that release.
if requested_tag and requested_tag != "latest":
yield _release(requested_tag)
return
for tag in self.TAGS:
yield _release(tag)
monkeypatch.setattr(
INSTALL_LLAMA_PREBUILT, "iter_release_payloads_by_time", fake_iter
)
return calls
def test_pre26_host_pins_b9415_without_walkback(self, monkeypatch):
calls = self._feed(monkeypatch)
host = _macos_host("arm64", (15, 5))
requested_tag, plans = resolve_simple_install_release_plans(
"latest", host, "ggml-org/llama.cpp", ""
)
assert requested_tag == "b9415"
assert len(plans) == 1
assert plans[0].release_tag == "b9415"
assert plans[0].llama_tag == "b9415"
assert plans[0].attempts[0].install_kind == "macos-arm64"
assert plans[0].attempts[0].name == "llama-b9415-bin-macos-arm64.tar.gz"
# The pin overrode the requested tag before any release was fetched.
assert calls[0][2] == "b9415"
# Simple/upstream path stays unverified-by-manifest, exactly as before.
assert plans[0].approved_checksums.artifacts == {}
def test_tahoe_host_takes_latest_release(self, monkeypatch):
calls = self._feed(monkeypatch)
host = _macos_host("arm64", (26, 0))
requested_tag, plans = resolve_simple_install_release_plans(
"latest", host, "ggml-org/llama.cpp", ""
)
assert requested_tag == "latest"
assert plans[0].release_tag == "b9442"
# No pin: the iterator was asked for latest, not a specific tag.
assert calls[0][2] == "latest"

View file

@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "2026.5.8"
__version__ = "2026.5.9"
__all__ = [
"SUPPORTS_BFLOAT16",