Merge branch 'main' into tool-call-confirmation
This commit is contained in:
commit
27331f1f5e
21 changed files with 1449 additions and 143 deletions
14
README.md
14
README.md
|
|
@ -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:
|
||||
|
|
|
|||
10
install.ps1
10
install.ps1
|
|
@ -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)
|
||||
|
|
|
|||
54
install.sh
54
install.sh
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -2591,6 +2591,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 +3482,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -568,17 +571,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
|
||||
|
|
@ -630,6 +635,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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
236
studio/backend/tests/test_mcp_stdio_improvements.py
Normal file
236
studio/backend/tests/test_mcp_stdio_improvements.py
Normal 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
|
||||
367
studio/backend/tests/test_mcp_stdio_pr5863.py
Normal file
367
studio/backend/tests/test_mcp_stdio_pr5863.py
Normal 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"}
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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]] = {
|
||||
|
|
@ -1616,6 +1621,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,
|
||||
|
|
@ -1629,15 +1652,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
|
||||
|
||||
|
|
@ -5303,9 +5326,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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: _fake_macos_releases(
|
||||
self.TAGS
|
||||
),
|
||||
)
|
||||
def fake_iter(repo, published_release_tag, requested_tag):
|
||||
# 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)
|
||||
|
|
|
|||
|
|
@ -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 = ""):
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue